Compare commits

..

25 Commits

Author SHA1 Message Date
xiaomakuaiz b8f2b95f22 完善 OpenAI API 兼容性:增强 OpenAIContentPart 结构体
- 添加 ImageURL 字段支持 image_url 类型的内容部分
- 新增 OpenAIContentPartURL 结构体处理嵌套的 URL 对象
- 修复 MarshalJSON 使用指针接收器以保持与 UnmarshalJSON 的一致性
- 修复 String 方法的空格逻辑,使用 builder.Len() 替代索引判断

修复了测试用例中 image_url 数据被静默忽略的问题,确保正确解析包含图片 URL 的混合内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:41:37 +08:00
xiaomakuaiz 98c602819f lint: make golangci-lint happy
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:41:37 +08:00
xiaomakuaiz be163a5f80 优化 OpenAI API 兼容性实现
根据 PR #1512 代码审查意见,进行以下优化:

**安全性修复 (P0)**
- 修复 JSON 注入安全漏洞:移除不安全的字符串拼接构造 JSON 的方式
- 添加 NewStringContent 和 NewArrayContent 构造函数,直接构造对象而非通过 JSON 序列化

**性能优化**
- String() 方法使用 strings.Builder 替代字符串拼接,提升性能
- 多个 text 部分之间添加空格分隔符,避免语义错误

**测试覆盖**
- 添加完整的单元测试覆盖 MessageContent 类型
- 测试字符串格式解析(包括特殊字符、Unicode、换行符等)
- 测试数组格式解析(单个/多个 text 部分、混合类型)
- 测试无效输入处理
- 测试序列化/反序列化往返

**代码改进**
- 更新 handler/share/chat.go 使用安全的构造函数
- 所有测试通过验证

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>

Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:26:29 +08:00
xiaomakuaiz 4c03078103 修复 /share/v1/chat/completions 接口 OpenAI 兼容性问题
现有的 OpenAI API 兼容接口不支持标准的 OpenAI messages 格式,特别是当 content 字段为数组格式时会解析失败。

1. **扩展 MessageContent 类型**:实现自定义的 JSON 序列化/反序列化,支持 content 既可以是字符串,也可以是包含 text/type 的对象数组
2. **添加 stream_options 支持**:支持 OpenAI 标准的 stream_options 参数(如 include_usage)
3. **更新响应格式**:在流式响应中添加 usage 字段支持,符合 OpenAI 标准

- `domain/openai.go`:
  - 新增 `MessageContent` 类型及其 JSON 序列化方法
  - 新增 `OpenAIStreamOptions` 结构体
  - 更新 `OpenAIMessage.Content` 类型从 string 改为 *MessageContent
  - 在流式响应中添加 usage 字段

- `handler/share/chat.go`:
  - 更新消息内容提取逻辑,使用 MessageContent.String() 方法
  - 修复流式和非流式响应中的 content 序列化

- 已通过单元测试验证 MessageContent 可以正确解析字符串和数组格式
- 编译通过,无语法错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>

Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:26:29 +08:00
Coltea 2b372fd81d
Merge pull request #1521 from coltea/fix-widget-search-path
fix widget search path
2025-11-14 19:01:45 +08:00
Coltea 74c6ba131d
Merge pull request #1520 from guanweiwang/hotfix/bug
fix: 修复 浏览器将http自动升级为 https
2025-11-14 19:01:32 +08:00
Coltea 6c77246e34
Merge pull request #1522 from KuaiYu95/fe/apiurl
fix: api url
2025-11-14 19:01:09 +08:00
coltea d347482d31 fix widget search path 2025-11-14 19:00:15 +08:00
yu.kuai 92443dfafe fix: api url 2025-11-14 18:59:52 +08:00
Gavan 1dd2d93010 fix: 修复 浏览器将http自动升级为 https 2025-11-14 18:49:25 +08:00
Coltea 54bf1fd108
Merge pull request #1518 from guanweiwang/feature/more_feat
feat: 退出登录 & 对话记录 & md 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖
2025-11-14 18:34:28 +08:00
Coltea c23ce398d7
Merge pull request #1517 from coltea/feat-widget
feat 新版挂件机器人 && 用户登出 &&  文档目录页
2025-11-14 18:34:14 +08:00
coltea 2c90932f60 feat node detail folder 2025-11-14 18:33:30 +08:00
Gavan 035ce0284d feat: 退出登录 & 对话记录 & d 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖 2025-11-14 18:30:49 +08:00
Coltea 93559125c2
Merge pull request #1519 from KuaiYu95/fe/widget
网页挂件支持三种模式
2025-11-14 18:23:04 +08:00
yu.kuai 6c5cf256ac feat: 后台网页挂件新增配置项
feat: 前台网页挂件支持三种模式:悬浮球,侧边吸附,自定义按钮
fix: markdown 编辑器新增配置项:showAutocomplete,highlightActiveLine
fix: markdown 编辑器输入中午拼音与 placeholder 重叠
fix: 使用官方修复后的 migrateMathStrings.ts
fix: 使用官方修复后的 table-of-contents
2025-11-14 17:54:37 +08:00
coltea 23bdfc3d50 feat widget search 2025-11-14 15:53:21 +08:00
coltea 9008c537cd feat logout 2025-11-14 10:44:00 +08:00
Coltea 63a4a964b1
Merge pull request #1516 from guanweiwang/feature/perm
feat: 权限
2025-11-13 19:00:28 +08:00
Coltea 62f2b2eaf5
Merge pull request #1514 from guanweiwang/feature/ai_search
feat: 优化 ai 搜索弹窗
2025-11-13 18:59:54 +08:00
Gavan 5c1c6368b8 feat: add expandable sections for results and thinking content in AiQaContent; update styles and remove unused hooks 2025-11-13 18:59:13 +08:00
Gavan 7282503acf feat: 权限 2025-11-13 18:55:25 +08:00
Coltea 60a4177229
Merge pull request #1511 from coltea/feat-edition
feat edition
2025-11-13 18:52:20 +08:00
coltea 2f706a6100 fix share nodes position 2025-11-13 18:40:33 +08:00
coltea febcb06654 feat edition 2025-11-13 15:50:05 +08:00
113 changed files with 6865 additions and 2723 deletions

View File

@ -7,21 +7,22 @@ import (
) )
type ShareNodeDetailResp struct { type ShareNodeDetailResp struct {
ID string `json:"id"` ID string `json:"id"`
KbID string `json:"kb_id"` KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"` Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"` Status domain.NodeStatus `json:"status"`
Name string `json:"name"` Name string `json:"name"`
Content string `json:"content"` Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"` Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"` ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"` Permissions domain.NodePermissions `json:"permissions"`
CreatorId string `json:"creator_id"` CreatorId string `json:"creator_id"`
EditorId string `json:"editor_id"` EditorId string `json:"editor_id"`
PublisherId string `json:"publisher_id"` PublisherId string `json:"publisher_id"`
CreatorAccount string `json:"creator_account"` CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"` EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account"` PublisherAccount string `json:"publisher_account"`
List []*domain.ShareNodeListItemResp `json:"list" gorm:"-"`
} }

View File

@ -1,5 +1,4 @@
//go:build wireinject //go:build wireinject
// +build wireinject
package main package main

View File

@ -1,5 +1,4 @@
//go:build wireinject //go:build wireinject
// +build wireinject
package main package main

View File

@ -1,5 +1,4 @@
//go:build wireinject //go:build wireinject
// +build wireinject
package main package main

View File

@ -1,8 +1,6 @@
package consts package consts
import ( import (
"math"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@ -13,28 +11,13 @@ const ContextKeyEdition contextKey = "edition"
type LicenseEdition int32 type LicenseEdition int32
const ( const (
LicenseEditionFree LicenseEdition = 0 // 开源版 LicenseEditionFree LicenseEdition = 0 // 开源版
LicenseEditionContributor LicenseEdition = 1 // 联创版 LicenseEditionProfession LicenseEdition = 1 // 专业版
LicenseEditionEnterprise LicenseEdition = 2 // 企业版 LicenseEditionEnterprise LicenseEdition = 2 // 企业版
LicenseEditionBusiness LicenseEdition = 3 // 商业版
) )
func GetLicenseEdition(c echo.Context) LicenseEdition { func GetLicenseEdition(c echo.Context) LicenseEdition {
edition, _ := c.Get("edition").(LicenseEdition) edition, _ := c.Get("edition").(LicenseEdition)
return edition return edition
} }
func (e LicenseEdition) GetMaxAuth(sourceType SourceType) int {
switch e {
case LicenseEditionFree:
if sourceType == SourceTypeGitHub {
return 10
}
return 0
case LicenseEditionContributor:
return 10
case LicenseEditionEnterprise:
return math.MaxInt
default:
return 0
}
}

View File

@ -3478,7 +3478,7 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"share_chat" "Widget"
], ],
"summary": "ChatWidget", "summary": "ChatWidget",
"parameters": [ "parameters": [
@ -3509,6 +3509,52 @@ const docTemplate = `{
} }
} }
}, },
"/share/v1/chat/widget/search": {
"post": {
"description": "WidgetSearch",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Widget"
],
"summary": "WidgetSearch",
"parameters": [
{
"description": "Comment",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChatSearchReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ChatSearchResp"
}
}
}
]
}
}
}
}
},
"/share/v1/comment": { "/share/v1/comment": {
"post": { "post": {
"description": "CreateComment", "description": "CreateComment",
@ -4067,22 +4113,26 @@ const docTemplate = `{
"enum": [ "enum": [
0, 0,
1, 1,
2 2,
3
], ],
"x-enum-comments": { "x-enum-comments": {
"LicenseEditionContributor": "联创版", "LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版", "LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版" "LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
}, },
"x-enum-descriptions": [ "x-enum-descriptions": [
"开源版", "开源版",
"联创版", "专业版",
"企业版" "企业版",
"商业版"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"LicenseEditionFree", "LicenseEditionFree",
"LicenseEditionContributor", "LicenseEditionProfession",
"LicenseEditionEnterprise" "LicenseEditionEnterprise",
"LicenseEditionBusiness"
] ]
}, },
"consts.ModelSettingMode": { "consts.ModelSettingMode": {
@ -6311,6 +6361,9 @@ const docTemplate = `{
} }
} }
}, },
"domain.MessageContent": {
"type": "object"
},
"domain.MessageFrom": { "domain.MessageFrom": {
"type": "integer", "type": "integer",
"enum": [ "enum": [
@ -6712,6 +6765,9 @@ const docTemplate = `{
"stream": { "stream": {
"type": "boolean" "type": "boolean"
}, },
"stream_options": {
"$ref": "#/definitions/domain.OpenAIStreamOptions"
},
"temperature": { "temperature": {
"type": "number" "type": "number"
}, },
@ -6834,7 +6890,7 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"content": { "content": {
"type": "string" "$ref": "#/definitions/domain.MessageContent"
}, },
"name": { "name": {
"type": "string" "type": "string"
@ -6864,6 +6920,14 @@ const docTemplate = `{
} }
} }
}, },
"domain.OpenAIStreamOptions": {
"type": "object",
"properties": {
"include_usage": {
"type": "boolean"
}
}
},
"domain.OpenAITool": { "domain.OpenAITool": {
"type": "object", "type": "object",
"required": [ "required": [
@ -7130,6 +7194,35 @@ const docTemplate = `{
} }
} }
}, },
"domain.ShareNodeListItemResp": {
"type": "object",
"properties": {
"emoji": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"parent_id": {
"type": "string"
},
"permissions": {
"$ref": "#/definitions/domain.NodePermissions"
},
"position": {
"type": "number"
},
"type": {
"$ref": "#/definitions/domain.NodeType"
},
"updated_at": {
"type": "string"
}
}
},
"domain.SimpleAuth": { "domain.SimpleAuth": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7647,15 +7740,33 @@ const docTemplate = `{
"domain.WidgetBotSettings": { "domain.WidgetBotSettings": {
"type": "object", "type": "object",
"properties": { "properties": {
"btn_id": {
"type": "string"
},
"btn_logo": { "btn_logo": {
"type": "string" "type": "string"
}, },
"btn_position": {
"type": "string"
},
"btn_style": {
"type": "string"
},
"btn_text": { "btn_text": {
"type": "string" "type": "string"
}, },
"disclaimer": {
"type": "string"
},
"is_open": { "is_open": {
"type": "boolean" "type": "boolean"
}, },
"modal_position": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"recommend_node_ids": { "recommend_node_ids": {
"type": "array", "type": "array",
"items": { "items": {
@ -7668,6 +7779,9 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"search_mode": {
"type": "string"
},
"theme_mode": { "theme_mode": {
"type": "string" "type": "string"
} }
@ -8536,6 +8650,12 @@ const docTemplate = `{
"kb_id": { "kb_id": {
"type": "string" "type": "string"
}, },
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShareNodeListItemResp"
}
},
"meta": { "meta": {
"$ref": "#/definitions/domain.NodeMeta" "$ref": "#/definitions/domain.NodeMeta"
}, },

View File

@ -3471,7 +3471,7 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"share_chat" "Widget"
], ],
"summary": "ChatWidget", "summary": "ChatWidget",
"parameters": [ "parameters": [
@ -3502,6 +3502,52 @@
} }
} }
}, },
"/share/v1/chat/widget/search": {
"post": {
"description": "WidgetSearch",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Widget"
],
"summary": "WidgetSearch",
"parameters": [
{
"description": "Comment",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChatSearchReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ChatSearchResp"
}
}
}
]
}
}
}
}
},
"/share/v1/comment": { "/share/v1/comment": {
"post": { "post": {
"description": "CreateComment", "description": "CreateComment",
@ -4060,22 +4106,26 @@
"enum": [ "enum": [
0, 0,
1, 1,
2 2,
3
], ],
"x-enum-comments": { "x-enum-comments": {
"LicenseEditionContributor": "联创版", "LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版", "LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版" "LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
}, },
"x-enum-descriptions": [ "x-enum-descriptions": [
"开源版", "开源版",
"联创版", "专业版",
"企业版" "企业版",
"商业版"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"LicenseEditionFree", "LicenseEditionFree",
"LicenseEditionContributor", "LicenseEditionProfession",
"LicenseEditionEnterprise" "LicenseEditionEnterprise",
"LicenseEditionBusiness"
] ]
}, },
"consts.ModelSettingMode": { "consts.ModelSettingMode": {
@ -6304,6 +6354,9 @@
} }
} }
}, },
"domain.MessageContent": {
"type": "object"
},
"domain.MessageFrom": { "domain.MessageFrom": {
"type": "integer", "type": "integer",
"enum": [ "enum": [
@ -6705,6 +6758,9 @@
"stream": { "stream": {
"type": "boolean" "type": "boolean"
}, },
"stream_options": {
"$ref": "#/definitions/domain.OpenAIStreamOptions"
},
"temperature": { "temperature": {
"type": "number" "type": "number"
}, },
@ -6827,7 +6883,7 @@
], ],
"properties": { "properties": {
"content": { "content": {
"type": "string" "$ref": "#/definitions/domain.MessageContent"
}, },
"name": { "name": {
"type": "string" "type": "string"
@ -6857,6 +6913,14 @@
} }
} }
}, },
"domain.OpenAIStreamOptions": {
"type": "object",
"properties": {
"include_usage": {
"type": "boolean"
}
}
},
"domain.OpenAITool": { "domain.OpenAITool": {
"type": "object", "type": "object",
"required": [ "required": [
@ -7123,6 +7187,35 @@
} }
} }
}, },
"domain.ShareNodeListItemResp": {
"type": "object",
"properties": {
"emoji": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"parent_id": {
"type": "string"
},
"permissions": {
"$ref": "#/definitions/domain.NodePermissions"
},
"position": {
"type": "number"
},
"type": {
"$ref": "#/definitions/domain.NodeType"
},
"updated_at": {
"type": "string"
}
}
},
"domain.SimpleAuth": { "domain.SimpleAuth": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7640,15 +7733,33 @@
"domain.WidgetBotSettings": { "domain.WidgetBotSettings": {
"type": "object", "type": "object",
"properties": { "properties": {
"btn_id": {
"type": "string"
},
"btn_logo": { "btn_logo": {
"type": "string" "type": "string"
}, },
"btn_position": {
"type": "string"
},
"btn_style": {
"type": "string"
},
"btn_text": { "btn_text": {
"type": "string" "type": "string"
}, },
"disclaimer": {
"type": "string"
},
"is_open": { "is_open": {
"type": "boolean" "type": "boolean"
}, },
"modal_position": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"recommend_node_ids": { "recommend_node_ids": {
"type": "array", "type": "array",
"items": { "items": {
@ -7661,6 +7772,9 @@
"type": "string" "type": "string"
} }
}, },
"search_mode": {
"type": "string"
},
"theme_mode": { "theme_mode": {
"type": "string" "type": "string"
} }
@ -8529,6 +8643,12 @@
"kb_id": { "kb_id": {
"type": "string" "type": "string"
}, },
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShareNodeListItemResp"
}
},
"meta": { "meta": {
"$ref": "#/definitions/domain.NodeMeta" "$ref": "#/definitions/domain.NodeMeta"
}, },

View File

@ -117,20 +117,24 @@ definitions:
- 0 - 0
- 1 - 1
- 2 - 2
- 3
format: int32 format: int32
type: integer type: integer
x-enum-comments: x-enum-comments:
LicenseEditionContributor: 联创 LicenseEditionBusiness: 商业
LicenseEditionEnterprise: 企业版 LicenseEditionEnterprise: 企业版
LicenseEditionFree: 开源版 LicenseEditionFree: 开源版
LicenseEditionProfession: 专业版
x-enum-descriptions: x-enum-descriptions:
- 开源版 - 开源版
- 联创 - 专业
- 企业版 - 企业版
- 商业版
x-enum-varnames: x-enum-varnames:
- LicenseEditionFree - LicenseEditionFree
- LicenseEditionContributor - LicenseEditionProfession
- LicenseEditionEnterprise - LicenseEditionEnterprise
- LicenseEditionBusiness
consts.ModelSettingMode: consts.ModelSettingMode:
enum: enum:
- manual - manual
@ -1610,6 +1614,8 @@ definitions:
url: url:
type: string type: string
type: object type: object
domain.MessageContent:
type: object
domain.MessageFrom: domain.MessageFrom:
enum: enum:
- 1 - 1
@ -1871,6 +1877,8 @@ definitions:
type: array type: array
stream: stream:
type: boolean type: boolean
stream_options:
$ref: '#/definitions/domain.OpenAIStreamOptions'
temperature: temperature:
type: number type: number
tool_choice: tool_choice:
@ -1952,7 +1960,7 @@ definitions:
domain.OpenAIMessage: domain.OpenAIMessage:
properties: properties:
content: content:
type: string $ref: '#/definitions/domain.MessageContent'
name: name:
type: string type: string
role: role:
@ -1973,6 +1981,11 @@ definitions:
required: required:
- type - type
type: object type: object
domain.OpenAIStreamOptions:
properties:
include_usage:
type: boolean
type: object
domain.OpenAITool: domain.OpenAITool:
properties: properties:
function: function:
@ -2146,6 +2159,25 @@ definitions:
role: role:
$ref: '#/definitions/schema.RoleType' $ref: '#/definitions/schema.RoleType'
type: object type: object
domain.ShareNodeListItemResp:
properties:
emoji:
type: string
id:
type: string
name:
type: string
parent_id:
type: string
permissions:
$ref: '#/definitions/domain.NodePermissions'
position:
type: number
type:
$ref: '#/definitions/domain.NodeType'
updated_at:
type: string
type: object
domain.SimpleAuth: domain.SimpleAuth:
properties: properties:
enabled: enabled:
@ -2488,12 +2520,24 @@ definitions:
type: object type: object
domain.WidgetBotSettings: domain.WidgetBotSettings:
properties: properties:
btn_id:
type: string
btn_logo: btn_logo:
type: string type: string
btn_position:
type: string
btn_style:
type: string
btn_text: btn_text:
type: string type: string
disclaimer:
type: string
is_open: is_open:
type: boolean type: boolean
modal_position:
type: string
placeholder:
type: string
recommend_node_ids: recommend_node_ids:
items: items:
type: string type: string
@ -2502,6 +2546,8 @@ definitions:
items: items:
type: string type: string
type: array type: array
search_mode:
type: string
theme_mode: theme_mode:
type: string type: string
type: object type: object
@ -3075,6 +3121,10 @@ definitions:
type: string type: string
kb_id: kb_id:
type: string type: string
list:
items:
$ref: '#/definitions/domain.ShareNodeListItemResp'
type: array
meta: meta:
$ref: '#/definitions/domain.NodeMeta' $ref: '#/definitions/domain.NodeMeta'
name: name:
@ -5296,7 +5346,34 @@ paths:
$ref: '#/definitions/domain.Response' $ref: '#/definitions/domain.Response'
summary: ChatWidget summary: ChatWidget
tags: tags:
- share_chat - Widget
/share/v1/chat/widget/search:
post:
consumes:
- application/json
description: WidgetSearch
parameters:
- description: Comment
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.ChatSearchReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/domain.ChatSearchResp'
type: object
summary: WidgetSearch
tags:
- Widget
/share/v1/comment: /share/v1/comment:
post: post:
consumes: consumes:

View File

@ -405,6 +405,13 @@ type WidgetBotSettings struct {
BtnLogo string `json:"btn_logo,omitempty"` BtnLogo string `json:"btn_logo,omitempty"`
RecommendQuestions []string `json:"recommend_questions,omitempty"` RecommendQuestions []string `json:"recommend_questions,omitempty"`
RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"` RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"`
BtnStyle string `json:"btn_style,omitempty"`
BtnID string `json:"btn_id,omitempty"`
BtnPosition string `json:"btn_position,omitempty"`
ModalPosition string `json:"modal_position,omitempty"`
SearchMode string `json:"search_mode,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
} }
type BrandGroup struct { type BrandGroup struct {

43
backend/domain/license.go Normal file
View File

@ -0,0 +1,43 @@
package domain
import (
"context"
"encoding/json"
)
const ContextKeyEditionLimitation contextKey = "edition_limitation"
type BaseEditionLimitation struct {
MaxKb int `json:"max_kb"` // 知识库站点数量
MaxNode int `json:"max_node"` // 单个知识库下文档数量
MaxSSOUser int `json:"max_sso_users"` // SSO认证用户数量
MaxAdmin int64 `json:"max_admin"` // 后台管理员数量
AllowAdminPerm bool `json:"allow_admin_perm"` // 支持管理员分权控制
AllowCustomCopyright bool `json:"allow_custom_copyright"` // 支持自定义版权信息
AllowCommentAudit bool `json:"allow_comment_audit"` // 支持评论审核
AllowAdvancedBot bool `json:"allow_advanced_bot"` // 支持高级机器人配置
AllowWatermark bool `json:"allow_watermark"` // 支持水印
AllowCopyProtection bool `json:"allow_copy_protection"` // 支持内容复制保护
AllowOpenAIBotSettings bool `json:"allow_open_ai_bot_settings"` // 支持问答机器人
}
var baseEditionLimitationDefault = BaseEditionLimitation{
MaxKb: 1,
MaxAdmin: 1,
MaxNode: 300,
}
func GetBaseEditionLimitation(c context.Context) BaseEditionLimitation {
edition, ok := c.Value(ContextKeyEditionLimitation).([]byte)
if !ok {
return baseEditionLimitationDefault
}
var editionLimitation BaseEditionLimitation
if err := json.Unmarshal(edition, &editionLimitation); err != nil {
return baseEditionLimitationDefault
}
return editionLimitation
}

View File

@ -37,8 +37,14 @@ type MessageContent struct {
// OpenAIContentPart 表示内容数组中的单个元素 // OpenAIContentPart 表示内容数组中的单个元素
type OpenAIContentPart struct { type OpenAIContentPart struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
ImageURL *OpenAIContentPartURL `json:"image_url,omitempty"`
}
// OpenAIContentPartURL represents the image_url field in content parts
type OpenAIContentPartURL struct {
URL string `json:"url"`
} }
// UnmarshalJSON 自定义解析,支持 string 或 array 格式 // UnmarshalJSON 自定义解析,支持 string 或 array 格式
@ -63,7 +69,7 @@ func (mc *MessageContent) UnmarshalJSON(data []byte) error {
} }
// MarshalJSON 自定义序列化 // MarshalJSON 自定义序列化
func (mc MessageContent) MarshalJSON() ([]byte, error) { func (mc *MessageContent) MarshalJSON() ([]byte, error) {
if mc.isString { if mc.isString {
return json.Marshal(mc.strValue) return json.Marshal(mc.strValue)
} }
@ -93,9 +99,9 @@ func (mc *MessageContent) String() string {
} }
// 从数组中提取文本 // 从数组中提取文本
var builder strings.Builder var builder strings.Builder
for i, part := range mc.arrValue { for _, part := range mc.arrValue {
if part.Type == "text" { if part.Type == "text" {
if i > 0 && part.Text != "" { if builder.Len() > 0 && part.Text != "" {
builder.WriteString(" ") builder.WriteString(" ")
} }
builder.WriteString(part.Text) builder.WriteString(part.Text)
@ -181,9 +187,9 @@ type OpenAIStreamResponse struct {
} }
type OpenAIStreamChoice struct { type OpenAIStreamChoice struct {
Index int `json:"index"` Index int `json:"index"`
Delta OpenAIMessage `json:"delta"` Delta OpenAIMessage `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"` FinishReason *string `json:"finish_reason,omitempty"`
} }
// OpenAI 错误响应结构体 // OpenAI 错误响应结构体

View File

@ -61,6 +61,7 @@ func NewShareChatHandler(
share.POST("/search", h.ChatSearch, h.ShareAuthMiddleware.Authorize) share.POST("/search", h.ChatSearch, h.ShareAuthMiddleware.Authorize)
share.POST("/completions", h.ChatCompletions) share.POST("/completions", h.ChatCompletions)
share.POST("/widget", h.ChatWidget) share.POST("/widget", h.ChatWidget)
share.POST("/widget/search", h.WidgetSearch)
share.POST("/feedback", h.FeedBack) share.POST("/feedback", h.FeedBack)
return h return h
} }
@ -131,7 +132,7 @@ func (h *ShareChatHandler) ChatMessage(c echo.Context) error {
// //
// @Summary ChatWidget // @Summary ChatWidget
// @Description ChatWidget // @Description ChatWidget
// @Tags share_chat // @Tags Widget
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param app_type query string true "app type" // @Param app_type query string true "app type"
@ -444,7 +445,7 @@ func stringPtr(s string) *string {
return &s return &s
} }
// ChatMessage chat search // ChatSearch searches chat messages in shared knowledge base
// //
// @Summary ChatSearch // @Summary ChatSearch
// @Description ChatSearch // @Description ChatSearch
@ -487,3 +488,43 @@ func (h *ShareChatHandler) ChatSearch(c echo.Context) error {
} }
return h.NewResponseWithData(c, resp) return h.NewResponseWithData(c, resp)
} }
// WidgetSearch
//
// @Summary WidgetSearch
// @Description WidgetSearch
// @Tags Widget
// @Accept json
// @Produce json
// @Param request body domain.ChatSearchReq true "Comment"
// @Success 200 {object} domain.Response{data=domain.ChatSearchResp}
// @Router /share/v1/chat/widget/search [post]
func (h *ShareChatHandler) WidgetSearch(c echo.Context) error {
var req domain.ChatSearchReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "parse request failed", err)
}
req.KBID = c.Request().Header.Get("X-KB-ID")
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
// validate widget info
widgetAppInfo, err := h.appUsecase.GetWidgetAppInfo(c.Request().Context(), req.KBID)
if err != nil {
h.logger.Error("get widget app info failed", log.Error(err))
return h.sendErrMsg(c, "get app info error")
}
if !widgetAppInfo.Settings.WidgetBotSettings.IsOpen {
return h.sendErrMsg(c, "widget is not open")
}
req.RemoteIP = c.RealIP()
resp, err := h.chatUsecase.Search(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to search docs", err)
}
return h.NewResponseWithData(c, resp)
}

View File

@ -6,7 +6,6 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain" "github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler" "github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log" "github.com/chaitin/panda-wiki/log"
@ -157,7 +156,7 @@ func (h *ShareCommentHandler) GetCommentList(c echo.Context) error {
} }
// 查询数据库获取所有评论-->0 所有, 12 为需要审核的评论 // 查询数据库获取所有评论-->0 所有, 12 为需要审核的评论
commentsList, err := h.usecase.GetCommentListByNodeID(ctx, nodeID, consts.GetLicenseEdition(c)) commentsList, err := h.usecase.GetCommentListByNodeID(ctx, nodeID)
if err != nil { if err != nil {
return h.NewResponseWithError(c, "failed to get comment list", err) return h.NewResponseWithError(c, "failed to get comment list", err)
} }

View File

@ -91,5 +91,15 @@ func (h *ShareNodeHandler) GetNodeDetail(c echo.Context) error {
if err != nil { if err != nil {
return h.NewResponseWithError(c, "failed to get node detail", err) return h.NewResponseWithError(c, "failed to get node detail", err)
} }
// If the node is a folder, return the list of child nodes
if node.Type == domain.NodeTypeFolder {
childNodes, err := h.usecase.GetNodeReleaseListByParentID(c.Request().Context(), kbID, id, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get child nodes", err)
}
node.List = childNodes
}
return h.NewResponseWithData(c, node) return h.NewResponseWithData(c, node)
} }

View File

@ -5,6 +5,7 @@ import (
v1 "github.com/chaitin/panda-wiki/api/kb/v1" v1 "github.com/chaitin/panda-wiki/api/kb/v1"
"github.com/chaitin/panda-wiki/consts" "github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
) )
// KBUserList // KBUserList
@ -55,8 +56,8 @@ func (h *KnowledgeBaseHandler) KBUserInvite(c echo.Context) error {
return h.NewResponseWithError(c, "validate request failed", err) return h.NewResponseWithError(c, "validate request failed", err)
} }
if consts.GetLicenseEdition(c) != consts.LicenseEditionEnterprise && req.Perm != consts.UserKBPermissionFullControl { if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "非企业版本只能使用完全控制权限", nil) return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
} }
err := h.usecase.KBUserInvite(c.Request().Context(), req) err := h.usecase.KBUserInvite(c.Request().Context(), req)
@ -87,8 +88,8 @@ func (h *KnowledgeBaseHandler) KBUserUpdate(c echo.Context) error {
return h.NewResponseWithError(c, "validate request failed", err) return h.NewResponseWithError(c, "validate request failed", err)
} }
if consts.GetLicenseEdition(c) != consts.LicenseEditionEnterprise && req.Perm != consts.UserKBPermissionFullControl { if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "非企业版本只能使用完全控制权限", nil) return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
} }
err := h.usecase.UpdateUserKB(c.Request().Context(), req) err := h.usecase.UpdateUserKB(c.Request().Context(), req)

View File

@ -91,11 +91,7 @@ func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c echo.Context) error {
return h.NewResponseWithError(c, "ports is required", nil) return h.NewResponseWithError(c, "ports is required", nil)
} }
req.MaxKB = 1 req.MaxKB = domain.GetBaseEditionLimitation(c.Request().Context()).MaxKb
maxKB := c.Get("max_kb")
if maxKB != nil {
req.MaxKB = maxKB.(int)
}
did, err := h.usecase.CreateKnowledgeBase(c.Request().Context(), &req) did, err := h.usecase.CreateKnowledgeBase(c.Request().Context(), &req)
if err != nil { if err != nil {

View File

@ -46,7 +46,7 @@ func NewModelHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *
return handler return handler
} }
// get model list // GetModelList
// //
// @Summary get model list // @Summary get model list
// @Description get model list // @Description get model list
@ -66,7 +66,7 @@ func (h *ModelHandler) GetModelList(c echo.Context) error {
return h.NewResponseWithData(c, models) return h.NewResponseWithData(c, models)
} }
// create model // CreateModel
// //
// @Summary create model // @Summary create model
// @Description create model // @Description create model
@ -85,9 +85,6 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err) return h.NewResponseWithError(c, "invalid request", err)
} }
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context() ctx := c.Request().Context()
param := domain.ModelParam{} param := domain.ModelParam{}
@ -112,7 +109,7 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithData(c, model) return h.NewResponseWithData(c, model)
} }
// update model // UpdateModel
// //
// @Description update model // @Description update model
// @Tags model // @Tags model
@ -130,9 +127,6 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err) return h.NewResponseWithError(c, "invalid request", err)
} }
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context() ctx := c.Request().Context()
if err := h.usecase.Update(ctx, &req); err != nil { if err := h.usecase.Update(ctx, &req); err != nil {
return h.NewResponseWithError(c, "update model failed", err) return h.NewResponseWithError(c, "update model failed", err)
@ -140,7 +134,7 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithData(c, nil) return h.NewResponseWithData(c, nil)
} }
// check model // CheckModel
// //
// @Summary check model // @Summary check model
// @Description check model // @Description check model

View File

@ -81,15 +81,13 @@ func (h *NodeHandler) CreateNode(c echo.Context) error {
if err := c.Validate(req); err != nil { if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err) return h.NewResponseWithError(c, "validate request body failed", err)
} }
req.MaxNode = 300
if maxNode := c.Get("max_node"); maxNode != nil { req.MaxNode = domain.GetBaseEditionLimitation(ctx).MaxNode
req.MaxNode = maxNode.(int)
}
id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId) id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId)
if err != nil { if err != nil {
if errors.Is(err, domain.ErrMaxNodeLimitReached) { if errors.Is(err, domain.ErrMaxNodeLimitReached) {
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到联创版或企业版", nil) return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到更高版本", nil)
} }
return h.NewResponseWithError(c, "create node failed", err) return h.NewResponseWithError(c, "create node failed", err)
} }

View File

@ -15,7 +15,7 @@ type AuthMiddleware interface {
Authorize(next echo.HandlerFunc) echo.HandlerFunc Authorize(next echo.HandlerFunc) echo.HandlerFunc
ValidateUserRole(role consts.UserRole) echo.MiddlewareFunc ValidateUserRole(role consts.UserRole) echo.MiddlewareFunc
ValidateKBUserPerm(role consts.UserKBPermission) echo.MiddlewareFunc ValidateKBUserPerm(role consts.UserKBPermission) echo.MiddlewareFunc
ValidateLicenseEdition(edition consts.LicenseEdition) echo.MiddlewareFunc ValidateLicenseEdition(edition ...consts.LicenseEdition) echo.MiddlewareFunc
MustGetUserID(c echo.Context) (string, bool) MustGetUserID(c echo.Context) (string, bool)
} }

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"slices"
"strings" "strings"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -194,7 +195,7 @@ func (m *JWTMiddleware) ValidateKBUserPerm(perm consts.UserKBPermission) echo.Mi
} }
} }
func (m *JWTMiddleware) ValidateLicenseEdition(needEdition consts.LicenseEdition) echo.MiddlewareFunc { func (m *JWTMiddleware) ValidateLicenseEdition(needEditions ...consts.LicenseEdition) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@ -206,7 +207,7 @@ func (m *JWTMiddleware) ValidateLicenseEdition(needEdition consts.LicenseEdition
}) })
} }
if edition < needEdition { if !slices.Contains(needEditions, edition) {
return c.JSON(http.StatusForbidden, domain.PWResponse{ return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false, Success: false,
Message: "Unauthorized ValidateLicenseEdition", Message: "Unauthorized ValidateLicenseEdition",

@ -1 +1 @@
Subproject commit c4dc498df094cb617d31c95580db8239a445d652 Subproject commit bb1b17dd5c7d72d40f6a1198b1604f4d3c44116e

View File

@ -300,8 +300,8 @@ func (r *AuthRepo) GetOrCreateAuth(ctx context.Context, auth *domain.Auth, sourc
return err return err
} }
if int(count) >= licenseEdition.GetMaxAuth(sourceType) { if int(count) >= domain.GetBaseEditionLimitation(ctx).MaxSSOUser {
return fmt.Errorf("exceed max auth limit for kb %s, current count: %d, max limit: %d", auth.KBID, count, licenseEdition.GetMaxAuth(sourceType)) return fmt.Errorf("exceed max auth limit for kb %s, current count: %d, max limit: %d", auth.KBID, count, domain.GetBaseEditionLimitation(ctx).MaxSSOUser)
} }
auth.LastLoginTime = time.Now() auth.LastLoginTime = time.Now()

View File

@ -26,12 +26,12 @@ func (r *CommentRepository) CreateComment(ctx context.Context, comment *domain.C
return nil return nil
} }
func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string, edition consts.LicenseEdition) ([]*domain.ShareCommentListItem, int64, error) { func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string) ([]*domain.ShareCommentListItem, int64, error) {
// 按照时间排序来查询node_id的comments // 按照时间排序来查询node_id的comments
var comments []*domain.ShareCommentListItem var comments []*domain.ShareCommentListItem
query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("node_id = ?", nodeID) query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("node_id = ?", nodeID)
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionEnterprise { if domain.GetBaseEditionLimitation(ctx).AllowCommentAudit {
query = query.Where("status = ?", domain.CommentStatusAccepted) //accepted query = query.Where("status = ?", domain.CommentStatusAccepted) //accepted
} }
@ -50,14 +50,14 @@ func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string, e
func (r *CommentRepository) GetCommentListByKbID(ctx context.Context, req *domain.CommentListReq, edition consts.LicenseEdition) ([]*domain.CommentListItem, int64, error) { func (r *CommentRepository) GetCommentListByKbID(ctx context.Context, req *domain.CommentListReq, edition consts.LicenseEdition) ([]*domain.CommentListItem, int64, error) {
comments := []*domain.CommentListItem{} comments := []*domain.CommentListItem{}
query := r.db.Model(&domain.Comment{}).Where("comments.kb_id = ?", req.KbID) query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("comments.kb_id = ?", req.KbID)
var count int64 var count int64
if req.Status == nil { if req.Status == nil {
if err := query.Count(&count).Error; err != nil { if err := query.Count(&count).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
} else { } else {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionEnterprise { if domain.GetBaseEditionLimitation(ctx).AllowCommentAudit {
query = query.Where("comments.status = ?", *req.Status) query = query.Where("comments.status = ?", *req.Status)
} }
// 按照时间排序来查询kb_id的comments ->reject pending accepted // 按照时间排序来查询kb_id的comments ->reject pending accepted
@ -84,7 +84,7 @@ func (r *CommentRepository) GetCommentListByKbID(ctx context.Context, req *domai
func (r *CommentRepository) DeleteCommentList(ctx context.Context, commentID []string) error { func (r *CommentRepository) DeleteCommentList(ctx context.Context, commentID []string) error {
// 批量删除指定id的comment,获取删除的总的数量、 // 批量删除指定id的comment,获取删除的总的数量、
query := r.db.Model(&domain.Comment{}).Where("id IN (?)", commentID) query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("id IN (?)", commentID)
if err := query.Delete(&domain.Comment{}).Error; err != nil { if err := query.Delete(&domain.Comment{}).Error; err != nil {
return err return err

View File

@ -341,11 +341,12 @@ func (r *KnowledgeBaseRepository) CreateKnowledgeBase(ctx context.Context, maxKB
Name: kb.Name, Name: kb.Name,
Type: domain.AppTypeWeb, Type: domain.AppTypeWeb,
Settings: domain.AppSettings{ Settings: domain.AppSettings{
Title: kb.Name, Title: kb.Name,
Desc: kb.Name, Desc: kb.Name,
Keyword: kb.Name, Keyword: kb.Name,
Icon: domain.DefaultPandaWikiIconB64, AutoSitemap: true,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name), Icon: domain.DefaultPandaWikiIconB64,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name),
Btns: []any{ Btns: []any{
AppBtn{ AppBtn{
ID: uuid.New().String(), ID: uuid.New().String(),

View File

@ -683,7 +683,7 @@ func (r *NodeRepository) GetNodeReleaseListByKBID(ctx context.Context, kbID stri
Where("kb_release_node_releases.kb_id = ?", kbID). Where("kb_release_node_releases.kb_id = ?", kbID).
Where("kb_release_node_releases.release_id = ?", kbRelease.ID). Where("kb_release_node_releases.release_id = ?", kbRelease.ID).
Where("nodes.permissions->>'visible' != ?", consts.NodeAccessPermClosed). Where("nodes.permissions->>'visible' != ?", consts.NodeAccessPermClosed).
Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, node_releases.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions"). Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, nodes.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions").
Find(&nodes).Error; err != nil { Find(&nodes).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -60,18 +60,14 @@ func (r *UserRepository) CreateUser(ctx context.Context, user *domain.User, edit
} }
user.Password = string(hashedPassword) user.Password = string(hashedPassword)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionFree { var count int64
var count int64 if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil { return err
return err
}
if edition == consts.LicenseEditionFree && count >= 1 {
return errors.New("free edition only allows 1 user")
}
if edition == consts.LicenseEditionContributor && count >= 5 {
return errors.New("contributor edition only allows 5 user")
}
} }
if count >= domain.GetBaseEditionLimitation(ctx).MaxAdmin {
return fmt.Errorf("exceed max admin limit, current count: %d, max limit: %d", count, domain.GetBaseEditionLimitation(ctx).MaxAdmin)
}
if err := tx.Create(user).Error; err != nil { if err := tx.Create(user).Error; err != nil {
return err return err
} }

View File

@ -88,34 +88,38 @@ func NewAppUsecase(
} }
func (u *AppUsecase) ValidateUpdateApp(ctx context.Context, id string, req *domain.UpdateAppReq, edition consts.LicenseEdition) error { func (u *AppUsecase) ValidateUpdateApp(ctx context.Context, id string, req *domain.UpdateAppReq, edition consts.LicenseEdition) error {
switch edition { app, err := u.repo.GetAppDetail(ctx, id)
case consts.LicenseEditionFree: if err != nil {
app, err := u.repo.GetAppDetail(ctx, id) return err
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.ContributeSettings != req.Settings.ContributeSettings ||
app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionContributor:
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionEnterprise:
return nil
default:
return fmt.Errorf("unsupported license type: %d", edition)
} }
limitation := domain.GetBaseEditionLimitation(ctx)
if !limitation.AllowCopyProtection && app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
if !limitation.AllowWatermark {
if app.Settings.WatermarkSetting != req.Settings.WatermarkSetting || app.Settings.WatermarkContent != req.Settings.WatermarkContent {
return domain.ErrPermissionDenied
}
}
if !limitation.AllowAdvancedBot {
if !slices.Equal(app.Settings.WechatServiceContainKeywords, req.Settings.WechatServiceContainKeywords) ||
!slices.Equal(app.Settings.WechatServiceEqualKeywords, req.Settings.WechatServiceEqualKeywords) {
return domain.ErrPermissionDenied
}
}
if !limitation.AllowCommentAudit && app.Settings.WebAppCommentSettings.ModerationEnable != req.Settings.WebAppCommentSettings.ModerationEnable {
return domain.ErrPermissionDenied
}
if !limitation.AllowOpenAIBotSettings {
if app.Settings.OpenAIAPIBotSettings.IsEnabled != req.Settings.OpenAIAPIBotSettings.IsEnabled || app.Settings.OpenAIAPIBotSettings.SecretKey != req.Settings.OpenAIAPIBotSettings.SecretKey {
return domain.ErrPermissionDenied
}
}
return nil return nil
} }
@ -618,8 +622,8 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
} }
showBrand := true showBrand := true
defaultDisclaimer := "本回答由 PandaWiki 基于 AI 生成,仅供参考。" defaultDisclaimer := "本回答由 PandaWiki 基于 AI 生成,仅供参考。"
licenseEdition, _ := ctx.Value(consts.ContextKeyEdition).(consts.LicenseEdition)
if licenseEdition < consts.LicenseEditionEnterprise { if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
appInfo.Settings.WebAppCustomSettings.ShowBrandInfo = &showBrand appInfo.Settings.WebAppCustomSettings.ShowBrandInfo = &showBrand
appInfo.Settings.DisclaimerSettings.Content = &defaultDisclaimer appInfo.Settings.DisclaimerSettings.Content = &defaultDisclaimer
} else { } else {

View File

@ -72,8 +72,8 @@ func (u *CommentUsecase) CreateComment(ctx context.Context, commentReq *domain.C
return CommentStr, nil return CommentStr, nil
} }
func (u *CommentUsecase) GetCommentListByNodeID(ctx context.Context, nodeID string, edition consts.LicenseEdition) (*domain.PaginatedResult[[]*domain.ShareCommentListItem], error) { func (u *CommentUsecase) GetCommentListByNodeID(ctx context.Context, nodeID string) (*domain.PaginatedResult[[]*domain.ShareCommentListItem], error) {
comments, total, err := u.CommentRepo.GetCommentList(ctx, nodeID, edition) comments, total, err := u.CommentRepo.GetCommentList(ctx, nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -350,6 +350,56 @@ func (u *NodeUsecase) GetNodeReleaseListByKBID(ctx context.Context, kbID string,
return items, nil return items, nil
} }
func (u *NodeUsecase) GetNodeReleaseListByParentID(ctx context.Context, kbID, parentID string, authId uint) ([]*domain.ShareNodeListItemResp, error) {
// 一次性查询所有节点
allNodes, err := u.nodeRepo.GetNodeReleaseListByKBID(ctx, kbID)
if err != nil {
return nil, err
}
nodeGroupIds, err := u.GetNodeIdsByAuthId(ctx, authId, consts.NodePermNameVisible)
if err != nil {
return nil, err
}
// 先过滤权限
visibleNodes := make([]*domain.ShareNodeListItemResp, 0)
for i, node := range allNodes {
switch node.Permissions.Visible {
case consts.NodeAccessPermOpen:
visibleNodes = append(visibleNodes, allNodes[i])
case consts.NodeAccessPermPartial:
if slices.Contains(nodeGroupIds, node.ID) {
visibleNodes = append(visibleNodes, allNodes[i])
}
}
}
// 构建父子关系映射
childrenMap := make(map[string][]*domain.ShareNodeListItemResp)
for _, node := range visibleNodes {
childrenMap[node.ParentID] = append(childrenMap[node.ParentID], node)
}
// 递归收集所有后代节点
result := make([]*domain.ShareNodeListItemResp, 0)
u.collectDescendants(parentID, childrenMap, &result)
return result, nil
}
// collectDescendants 递归收集所有后代节点
func (u *NodeUsecase) collectDescendants(parentID string, childrenMap map[string][]*domain.ShareNodeListItemResp, result *[]*domain.ShareNodeListItemResp) {
children := childrenMap[parentID]
for _, child := range children {
*result = append(*result, child)
// 如果是文件夹,递归收集其子节点
if child.Type == domain.NodeTypeFolder {
u.collectDescendants(child.ID, childrenMap, result)
}
}
}
func (u *NodeUsecase) GetNodeIdsByAuthId(ctx context.Context, authId uint, PermName consts.NodePermName) ([]string, error) { func (u *NodeUsecase) GetNodeIdsByAuthId(ctx context.Context, authId uint, PermName consts.NodePermName) ([]string, error) {
authGroups, err := u.authRepo.GetAuthGroupWithParentsByAuthId(ctx, authId) authGroups, err := u.authRepo.GetAuthGroupWithParentsByAuthId(ctx, authId)
if err != nil { if err != nil {
@ -407,7 +457,7 @@ func (u *NodeUsecase) GetNodePermissionsByID(ctx context.Context, id, kbID strin
} }
func (u *NodeUsecase) ValidateNodePermissionsEdit(req v1.NodePermissionEditReq, edition consts.LicenseEdition) error { func (u *NodeUsecase) ValidateNodePermissionsEdit(req v1.NodePermissionEditReq, edition consts.LicenseEdition) error {
if edition != consts.LicenseEditionEnterprise { if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
if req.Permissions.Answerable == consts.NodeAccessPermPartial || req.Permissions.Visitable == consts.NodeAccessPermPartial || req.Permissions.Visible == consts.NodeAccessPermPartial { if req.Permissions.Answerable == consts.NodeAccessPermPartial || req.Permissions.Visitable == consts.NodeAccessPermPartial || req.Permissions.Visible == consts.NodeAccessPermPartial {
return domain.ErrPermissionDenied return domain.ErrPermissionDenied
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"sort" "sort"
"strconv" "strconv"
@ -67,12 +68,12 @@ func (u *StatUseCase) ValidateStatDay(statDay consts.StatDay, edition consts.Lic
case consts.StatDay1: case consts.StatDay1:
return nil return nil
case consts.StatDay7: case consts.StatDay7:
if edition < consts.LicenseEditionContributor { if edition == consts.LicenseEditionFree {
return domain.ErrPermissionDenied return domain.ErrPermissionDenied
} }
return nil return nil
case consts.StatDay30, consts.StatDay90: case consts.StatDay30, consts.StatDay90:
if edition < consts.LicenseEditionEnterprise { if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
return domain.ErrPermissionDenied return domain.ErrPermissionDenied
} }
return nil return nil

View File

@ -588,6 +588,7 @@ export type ChatConversationItem = {
export type ChatConversationPair = { export type ChatConversationPair = {
user: string; user: string;
assistant: string; assistant: string;
thinking_content: string;
created_at: string; created_at: string;
info: { info: {
feedback_content: string; feedback_content: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

View File

@ -144,7 +144,7 @@ const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
callback(); callback();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe]); }, [subscribe, appPreviewData, id]);
return ( return (
<StyledCommonWrapper> <StyledCommonWrapper>

View File

@ -10,6 +10,8 @@ import { setAppPreviewData } from '@/store/slices/config';
import { DomainSocialMediaAccount } from '@/request/types'; import { DomainSocialMediaAccount } from '@/request/types';
import Switch from '../basicComponents/Switch'; import Switch from '../basicComponents/Switch';
import DragSocialInfo from '../basicComponents/DragSocialInfo'; import DragSocialInfo from '../basicComponents/DragSocialInfo';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
interface FooterConfigProps { interface FooterConfigProps {
data?: AppDetail | null; data?: AppDetail | null;
@ -75,9 +77,6 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
); );
const footer_show_intro = watch('footer_show_intro'); const footer_show_intro = watch('footer_show_intro');
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
useEffect(() => { useEffect(() => {
if (isEdit && appPreviewData) { if (isEdit && appPreviewData) {
setValue( setValue(
@ -506,29 +505,33 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
)} )}
/> />
</Stack> </Stack>
{isEnterprise && (
<Stack direction={'column'} gap={2}> <Stack direction={'column'} gap={2}>
<Box <Box
sx={{ sx={{
fontSize: 14, fontSize: 14,
lineHeight: '22px', lineHeight: '22px',
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
fontWeight: 600, fontWeight: 600,
'&::before': { '&::before': {
content: '""', content: '""',
display: 'inline-block', display: 'inline-block',
width: 4, width: 4,
height: 12, height: 12,
bgcolor: '#3248F2', bgcolor: '#3248F2',
borderRadius: '2px', borderRadius: '2px',
mr: 1, mr: 1,
}, },
}} }}
> >
PandaWiki PandaWiki
</Box> </Box>
<VersionMask
permission={PROFESSION_VERSION_PERMISSION}
sx={{ inset: '-8px 0' }}
>
<Controller <Controller
control={control} control={control}
name='show_brand_info' name='show_brand_info'
@ -548,7 +551,6 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
<Switch <Switch
sx={{ marginLeft: 'auto' }} sx={{ marginLeft: 'auto' }}
{...field} {...field}
disabled={!isEnterprise}
checked={field?.value === false ? false : true} checked={field?.value === false ? false : true}
onChange={e => { onChange={e => {
field.onChange(e.target.checked); field.onChange(e.target.checked);
@ -558,8 +560,8 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
</Stack> </Stack>
)} )}
/> />
</Stack> </VersionMask>
)} </Stack>
</Stack> </Stack>
</> </>
); );

View File

@ -1,5 +1,6 @@
import { KnowledgeBaseListItem } from '@/api'; import { KnowledgeBaseListItem } from '@/api';
import { useURLSearchParams } from '@/hooks'; import { useURLSearchParams } from '@/hooks';
import { useFeatureValue } from '@/hooks';
import { ConstsUserRole } from '@/request/types'; import { ConstsUserRole } from '@/request/types';
import { useAppDispatch, useAppSelector } from '@/store'; import { useAppDispatch, useAppSelector } from '@/store';
import { setKbC, setKbId } from '@/store/slices/config'; import { setKbC, setKbId } from '@/store/slices/config';
@ -23,14 +24,14 @@ const KBSelect = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [_, setSearchParams] = useURLSearchParams(); const [_, setSearchParams] = useURLSearchParams();
const { kb_id, kbList, license, user } = useAppSelector( const { kb_id, kbList, user } = useAppSelector(state => state.config);
state => state.config,
);
const [modifyOpen, setModifyOpen] = useState(false); const [modifyOpen, setModifyOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [opraData, setOpraData] = useState<KnowledgeBaseListItem | null>(null); const [opraData, setOpraData] = useState<KnowledgeBaseListItem | null>(null);
const wikiCount = useFeatureValue('wikiCount');
return ( return (
<> <>
{(kbList || []).length > 0 && ( {(kbList || []).length > 0 && (
@ -121,8 +122,7 @@ const KBSelect = () => {
}} }}
fullWidth fullWidth
disabled={ disabled={
(license.edition === 0 && (kbList || []).length >= 1) || (kbList || []).length >= wikiCount ||
(license.edition === 1 && (kbList || []).length >= 3) ||
user.role === ConstsUserRole.UserRoleUser user.role === ConstsUserRole.UserRoleUser
} }
onClick={event => { onClick={event => {

View File

@ -3,27 +3,20 @@ import {
getApiV1License, getApiV1License,
deleteApiV1License, deleteApiV1License,
} from '@/request/pro/License'; } from '@/request/pro/License';
import { PostApiV1LicensePayload } from '@/request/pro/types';
import HelpCenter from '@/assets/json/help-center.json'; import HelpCenter from '@/assets/json/help-center.json';
import Takeoff from '@/assets/json/takeoff.json'; import Takeoff from '@/assets/json/takeoff.json';
import error from '@/assets/json/error.json'; import error from '@/assets/json/error.json';
import IconUpgrade from '@/assets/json/upgrade.json'; import IconUpgrade from '@/assets/json/upgrade.json';
import Upload from '@/components/UploadFile/Drag'; import Upload from '@/components/UploadFile/Drag';
import { EditionType } from '@/constant/enums'; import { useVersionInfo } from '@/hooks';
import { useAppDispatch, useAppSelector } from '@/store'; import { useAppDispatch, useAppSelector } from '@/store';
import { setLicense } from '@/store/slices/config'; import { setLicense } from '@/store/slices/config';
import { import { Box, Button, IconButton, Stack, TextField } from '@mui/material';
Box,
Button,
IconButton,
MenuItem,
Stack,
TextField,
} from '@mui/material';
import { CusTabs, Icon, message, Modal } from '@ctzhian/ui'; import { CusTabs, Icon, message, Modal } from '@ctzhian/ui';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useState } from 'react'; import { useState } from 'react';
import LottieIcon from '../LottieIcon'; import LottieIcon from '../LottieIcon';
import { ConstsLicenseEdition } from '@/request/types';
interface AuthTypeModalProps { interface AuthTypeModalProps {
open: boolean; open: boolean;
@ -42,10 +35,9 @@ const AuthTypeModal = ({
const { license } = useAppSelector(state => state.config); const { license } = useAppSelector(state => state.config);
const [selected, setSelected] = useState<'file' | 'code'>( const [selected, setSelected] = useState<'file' | 'code'>(
license.edition === 2 ? 'file' : 'code', license.edition === ConstsLicenseEdition.LicenseEditionEnterprise
); ? 'file'
const [authVersion, setAuthVersion] = useState<'contributor' | 'enterprise'>( : 'code',
license.edition === 2 ? 'enterprise' : 'contributor',
); );
const [updateOpen, setUpdateOpen] = useState(false); const [updateOpen, setUpdateOpen] = useState(false);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@ -53,16 +45,15 @@ const AuthTypeModal = ({
const [file, setFile] = useState<File | undefined>(undefined); const [file, setFile] = useState<File | undefined>(undefined);
const [unbindLoading, setUnbindLoading] = useState(false); const [unbindLoading, setUnbindLoading] = useState(false);
const versionInfo = useVersionInfo();
const handleSubmit = () => { const handleSubmit = () => {
const params: PostApiV1LicensePayload = { setLoading(true);
license_edition: authVersion, postApiV1License({
license_type: selected, license_type: selected,
license_code: code, license_code: code,
license_file: file, license_file: file,
}; })
setLoading(true);
postApiV1License(params)
.then(() => { .then(() => {
message.success('激活成功'); message.success('激活成功');
setUpdateOpen(false); setUpdateOpen(false);
@ -148,10 +139,8 @@ const AuthTypeModal = ({
<Stack direction={'row'} alignItems={'center'}> <Stack direction={'row'} alignItems={'center'}>
<Box sx={{ width: 120, flexShrink: 0 }}></Box> <Box sx={{ width: 120, flexShrink: 0 }}></Box>
<Stack direction={'row'} alignItems={'center'} gap={2}> <Stack direction={'row'} alignItems={'center'} gap={2}>
<Box sx={{ minWidth: 50 }}> <Box sx={{ minWidth: 50 }}>{versionInfo.label}</Box>
{EditionType[license.edition as keyof typeof EditionType].text} {license.edition === ConstsLicenseEdition.LicenseEditionFree ? (
</Box>
{license.edition === 0 ? (
<Stack direction={'row'} gap={2}> <Stack direction={'row'} gap={2}>
<Button <Button
size='small' size='small'
@ -240,7 +229,7 @@ const AuthTypeModal = ({
)} )}
</Stack> </Stack>
</Stack> </Stack>
{license.edition! > 0 && ( {license.edition! !== ConstsLicenseEdition.LicenseEditionFree && (
<Box> <Box>
<Stack direction={'row'} alignItems={'center'}> <Stack direction={'row'} alignItems={'center'}>
<Box sx={{ width: 120, flexShrink: 0 }}></Box> <Box sx={{ width: 120, flexShrink: 0 }}></Box>
@ -288,18 +277,6 @@ const AuthTypeModal = ({
value={selected} value={selected}
change={(v: string) => setSelected(v as 'file' | 'code')} change={(v: string) => setSelected(v as 'file' | 'code')}
/> />
<TextField
select
fullWidth
sx={{ mt: 2 }}
value={authVersion}
onChange={e =>
setAuthVersion(e.target.value as 'contributor' | 'enterprise')
}
>
<MenuItem value='contributor'></MenuItem>
<MenuItem value='enterprise'></MenuItem>
</TextField>
{selected === 'code' && ( {selected === 'code' && (
<TextField <TextField
sx={{ mt: 2 }} sx={{ mt: 2 }}

View File

@ -1,24 +1,14 @@
import HelpCenter from '@/assets/json/help-center.json'; import HelpCenter from '@/assets/json/help-center.json';
import IconUpgrade from '@/assets/json/upgrade.json'; import IconUpgrade from '@/assets/json/upgrade.json';
import LottieIcon from '@/components/LottieIcon'; import LottieIcon from '@/components/LottieIcon';
import { EditionType } from '@/constant/enums';
import { useAppSelector } from '@/store';
import { Box, Stack, Tooltip } from '@mui/material'; import { Box, Stack, Tooltip } from '@mui/material';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import packageJson from '../../../package.json'; import packageJson from '../../../package.json';
import AuthTypeModal from './AuthTypeModal'; import AuthTypeModal from './AuthTypeModal';
import freeVersion from '@/assets/images/free-version.png'; import { useVersionInfo } from '@/hooks';
import enterpriseVersion from '@/assets/images/enterprise-version.png';
import contributorVersion from '@/assets/images/contributor-version.png';
const versionMap = {
0: freeVersion,
1: contributorVersion,
2: enterpriseVersion,
};
const Version = () => { const Version = () => {
const { license } = useAppSelector(state => state.config); const versionInfo = useVersionInfo();
const curVersion = import.meta.env.VITE_APP_VERSION || packageJson.version; const curVersion = import.meta.env.VITE_APP_VERSION || packageJson.version;
const [latestVersion, setLatestVersion] = useState<string | undefined>( const [latestVersion, setLatestVersion] = useState<string | undefined>(
undefined, undefined,
@ -57,11 +47,8 @@ const Version = () => {
> >
<Stack direction={'row'} alignItems='center' gap={0.5}> <Stack direction={'row'} alignItems='center' gap={0.5}>
<Box sx={{ width: 30, color: 'text.tertiary' }}></Box> <Box sx={{ width: 30, color: 'text.tertiary' }}></Box>
<img <img src={versionInfo.image} style={{ height: 13, marginTop: -1 }} />
src={versionMap[license.edition!]} {versionInfo.label}
style={{ height: 13, marginTop: -1 }}
/>
{EditionType[license.edition as keyof typeof EditionType].text}
</Stack> </Stack>
<Stack direction={'row'} gap={0.5}> <Stack direction={'row'} gap={0.5}>
<Box sx={{ width: 30, color: 'text.tertiary' }}></Box> <Box sx={{ width: 30, color: 'text.tertiary' }}></Box>

View File

@ -9,7 +9,8 @@ import { Modal, message } from '@ctzhian/ui';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
import { ConstsUserKBPermission, V1KBUserInviteReq } from '@/request/types'; import { ConstsUserKBPermission, V1KBUserInviteReq } from '@/request/types';
import { ConstsLicenseEdition } from '@/request/pro/types'; import { ConstsLicenseEdition } from '@/request/pro/types';
@ -26,9 +27,13 @@ const VERSION_MAP = {
message: '开源版只支持 1 个管理员', message: '开源版只支持 1 个管理员',
max: 1, max: 1,
}, },
[ConstsLicenseEdition.LicenseEditionContributor]: { [ConstsLicenseEdition.LicenseEditionProfession]: {
message: '联创版最多支持 3 个管理员', message: '专业版最多支持 20 个管理员',
max: 3, max: 20,
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
message: '商业版最多支持 50 个管理员',
max: 50,
}, },
}; };
@ -45,9 +50,6 @@ const MemberAdd = ({
const { kbList, license, refreshAdminRequest } = useAppSelector( const { kbList, license, refreshAdminRequest } = useAppSelector(
state => state.config, state => state.config,
); );
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const { const {
control, control,
@ -118,6 +120,10 @@ const MemberAdd = ({
}); });
}); });
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
return ( return (
<> <>
<Button <Button
@ -253,6 +259,14 @@ const MemberAdd = ({
fullWidth fullWidth
displayEmpty displayEmpty
sx={{ height: 52 }} sx={{ height: 52 }}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
renderValue={(value: V1KBUserInviteReq['perm']) => { renderValue={(value: V1KBUserInviteReq['perm']) => {
return value ? ( return value ? (
PERM_MAP[value] PERM_MAP[value]
@ -266,17 +280,25 @@ const MemberAdd = ({
> >
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage} value={ConstsUserKBPermission.UserKBPermissionDocManage}
disabled={!isPro}
> >
{isEnterprise ? '' : '(企业版可用)'} {' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate} value={ConstsUserKBPermission.UserKBPermissionDataOperate}
disabled={!isPro}
> >
{isEnterprise ? '' : '(企业版可用)'} {' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem> </MenuItem>
</Select> </Select>
)} )}

View File

@ -0,0 +1,116 @@
import { styled } from '@mui/material';
import { useVersionInfo } from '@/hooks';
import { VersionInfoMap } from '@/constant/version';
import { ConstsLicenseEdition } from '@/request/types';
import { SxProps } from '@mui/material';
import React from 'react';
const StyledMaskWrapper = styled('div')(({ theme }) => ({
position: 'relative',
width: '100%',
height: '100%',
}));
const StyledMask = styled('div')(({ theme }) => ({
position: 'absolute',
inset: -8,
zIndex: 99,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flex: 1,
borderRadius: '10px',
border: `1px solid ${theme.palette.divider}`,
background: 'rgba(241,242,248,0.8)',
backdropFilter: 'blur(0.5px)',
}));
const StyledMaskContent = styled('div')(({ theme }) => ({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}));
const StyledMaskVersion = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
padding: theme.spacing(0.5, 1),
backgroundColor: theme.palette.background.paper3,
borderRadius: '10px',
fontSize: 12,
lineHeight: 1,
color: theme.palette.light.main,
}));
const VersionMask = ({
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
children,
sx,
}: {
permission?: ConstsLicenseEdition[];
children?: React.ReactNode;
sx?: SxProps;
}) => {
const versionInfo = useVersionInfo();
const hasPermission = permission.includes(versionInfo.permission);
if (hasPermission) return children;
const nextVersionInfo = VersionInfoMap[permission[0]];
return (
<StyledMaskWrapper>
{children}
<StyledMask sx={sx}>
<StyledMaskContent>
<StyledMaskVersion sx={{ backgroundColor: nextVersionInfo.bgColor }}>
<img
src={nextVersionInfo.image}
style={{ width: 12, objectFit: 'contain', marginTop: 1 }}
alt={nextVersionInfo.label}
/>
{nextVersionInfo?.label}
</StyledMaskVersion>
</StyledMaskContent>
</StyledMask>
</StyledMaskWrapper>
);
};
export const VersionCanUse = ({
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
sx,
}: {
permission?: ConstsLicenseEdition[];
sx?: SxProps;
}) => {
const versionInfo = useVersionInfo();
const hasPermission = permission.includes(versionInfo.permission);
if (hasPermission) return null;
const nextVersionInfo = VersionInfoMap[permission[0]];
return (
<StyledMaskContent sx={{ width: 'auto', ml: 1, ...sx }}>
<StyledMaskVersion sx={{ backgroundColor: nextVersionInfo.bgColor }}>
<img
src={nextVersionInfo.image}
style={{ width: 12, objectFit: 'contain', marginTop: 1 }}
alt={nextVersionInfo.label}
/>
{nextVersionInfo?.label}
</StyledMaskVersion>
</StyledMaskContent>
);
};
export default VersionMask;

View File

@ -797,21 +797,6 @@ export const FeedbackType = {
3: '其他', 3: '其他',
}; };
export const Free = 0;
export const Contributor = 1;
export const Enterprise = 2;
export const EditionType = {
[Free]: {
text: '开源版',
},
[Contributor]: {
text: '联创版',
},
[Enterprise]: {
text: '企业版',
},
};
export const DocWidth = { export const DocWidth = {
full: { full: {
label: '全屏', label: '全屏',

View File

@ -0,0 +1,293 @@
import { ConstsLicenseEdition } from '@/request/types';
import freeVersion from '@/assets/images/free-version.png';
import proVersion from '@/assets/images/pro-version.png';
import businessVersion from '@/assets/images/business-version.png';
import enterpriseVersion from '@/assets/images/enterprise-version.png';
export const PROFESSION_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const BUSINESS_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const ENTERPRISE_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const VersionInfoMap = {
[ConstsLicenseEdition.LicenseEditionFree]: {
permission: ConstsLicenseEdition.LicenseEditionFree,
label: '开源版',
image: freeVersion,
bgColor: '#8E9DAC',
nextVersion: ConstsLicenseEdition.LicenseEditionProfession,
},
[ConstsLicenseEdition.LicenseEditionProfession]: {
permission: ConstsLicenseEdition.LicenseEditionProfession,
label: '专业版',
image: proVersion,
bgColor: '#0933BA',
nextVersion: ConstsLicenseEdition.LicenseEditionBusiness,
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
permission: ConstsLicenseEdition.LicenseEditionBusiness,
label: '商业版',
image: businessVersion,
bgColor: '#382A79',
nextVersion: ConstsLicenseEdition.LicenseEditionEnterprise,
},
[ConstsLicenseEdition.LicenseEditionEnterprise]: {
permission: ConstsLicenseEdition.LicenseEditionEnterprise,
label: '企业版',
image: enterpriseVersion,
bgColor: '#21222D',
nextVersion: undefined,
},
};
/**
*
*/
export enum FeatureStatus {
/** 不支持 */
NOT_SUPPORTED = 'not_supported',
/** 支持 */
SUPPORTED = 'supported',
/** 基础配置 */
BASIC = 'basic',
/** 高级配置 */
ADVANCED = 'advanced',
}
/**
*
*/
export interface VersionInfo {
/** 版本名称 */
label: string;
/** 功能特性 */
features: {
/** Wiki 站点数量 */
wikiCount: number;
/** 每个 Wiki 的文档数量 */
docCountPerWiki: number;
/** 管理员数量 */
adminCount: number;
/** 管理员分权控制 */
adminPermissionControl: FeatureStatus;
/** SEO 配置 */
seoConfig: FeatureStatus;
/** 多语言支持 */
multiLanguage: FeatureStatus;
/** 自定义版权信息 */
customCopyright: FeatureStatus;
/** 访问流量分析 */
trafficAnalysis: FeatureStatus;
/** 自定义 AI 提示词 */
customAIPrompt: FeatureStatus;
/** SSO 登录 */
ssoLogin: number;
/** 访客权限控制 */
visitorPermissionControl: FeatureStatus;
/** 页面水印 */
pageWatermark: FeatureStatus;
/** 内容不可复制 */
contentNoCopy: FeatureStatus;
/** 敏感内容过滤 */
sensitiveContentFilter: FeatureStatus;
/** 网页挂件机器人 */
webWidgetRobot: FeatureStatus;
/** 飞书问答机器人 */
feishuQARobot: FeatureStatus;
/** 钉钉问答机器人 */
dingtalkQARobot: FeatureStatus;
/** 企业微信问答机器人 */
wecomQARobot: FeatureStatus;
/** 企业微信客服机器人 */
wecomServiceRobot: FeatureStatus;
/** Discord 问答机器人 */
discordQARobot: FeatureStatus;
/** 文档历史版本管理 */
docVersionHistory: FeatureStatus;
/** API 调用 */
apiCall: FeatureStatus;
/** 项目源码 */
sourceCode: FeatureStatus;
};
}
/**
*
*/
export const VERSION_INFO: Record<ConstsLicenseEdition, VersionInfo> = {
[ConstsLicenseEdition.LicenseEditionFree]: {
label: '开源版',
features: {
wikiCount: 1,
docCountPerWiki: 300,
adminCount: 1,
adminPermissionControl: FeatureStatus.NOT_SUPPORTED,
seoConfig: FeatureStatus.BASIC,
multiLanguage: FeatureStatus.NOT_SUPPORTED,
customCopyright: FeatureStatus.NOT_SUPPORTED,
trafficAnalysis: FeatureStatus.BASIC,
customAIPrompt: FeatureStatus.NOT_SUPPORTED,
ssoLogin: 0,
visitorPermissionControl: FeatureStatus.NOT_SUPPORTED,
pageWatermark: FeatureStatus.NOT_SUPPORTED,
contentNoCopy: FeatureStatus.NOT_SUPPORTED,
sensitiveContentFilter: FeatureStatus.NOT_SUPPORTED,
webWidgetRobot: FeatureStatus.BASIC,
feishuQARobot: FeatureStatus.BASIC,
dingtalkQARobot: FeatureStatus.BASIC,
wecomQARobot: FeatureStatus.BASIC,
wecomServiceRobot: FeatureStatus.BASIC,
discordQARobot: FeatureStatus.BASIC,
docVersionHistory: FeatureStatus.NOT_SUPPORTED,
apiCall: FeatureStatus.NOT_SUPPORTED,
sourceCode: FeatureStatus.SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionProfession]: {
label: '专业版',
features: {
wikiCount: 10,
docCountPerWiki: 10000,
adminCount: 20,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: 0,
visitorPermissionControl: FeatureStatus.NOT_SUPPORTED,
pageWatermark: FeatureStatus.NOT_SUPPORTED,
contentNoCopy: FeatureStatus.NOT_SUPPORTED,
sensitiveContentFilter: FeatureStatus.NOT_SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.NOT_SUPPORTED,
apiCall: FeatureStatus.NOT_SUPPORTED,
sourceCode: FeatureStatus.NOT_SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
label: '商业版',
features: {
wikiCount: 20,
docCountPerWiki: 10000,
adminCount: 50,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: 2000,
visitorPermissionControl: FeatureStatus.SUPPORTED,
pageWatermark: FeatureStatus.SUPPORTED,
contentNoCopy: FeatureStatus.SUPPORTED,
sensitiveContentFilter: FeatureStatus.SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.SUPPORTED,
apiCall: FeatureStatus.SUPPORTED,
sourceCode: FeatureStatus.NOT_SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionEnterprise]: {
label: '企业版',
features: {
wikiCount: Infinity,
docCountPerWiki: Infinity,
adminCount: Infinity,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: Infinity,
visitorPermissionControl: FeatureStatus.SUPPORTED,
pageWatermark: FeatureStatus.SUPPORTED,
contentNoCopy: FeatureStatus.SUPPORTED,
sensitiveContentFilter: FeatureStatus.SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.SUPPORTED,
apiCall: FeatureStatus.SUPPORTED,
sourceCode: FeatureStatus.SUPPORTED,
},
},
};
/**
*
*/
export const FEATURE_LABELS: Record<string, string> = {
wikiCount: 'Wiki 站点数量',
docCountPerWiki: '每个 Wiki 的文档数量',
adminCount: '管理员数量',
adminPermissionControl: '管理员分权控制',
seoConfig: 'SEO 配置',
multiLanguage: '多语言支持',
customCopyright: '自定义版权信息',
trafficAnalysis: '访问流量分析',
customAIPrompt: '自定义 AI 提示词',
ssoLogin: 'SSO 登录',
visitorPermissionControl: '访客权限控制',
pageWatermark: '页面水印',
contentNoCopy: '内容不可复制',
sensitiveContentFilter: '敏感内容过滤',
webWidgetRobot: '网页挂件机器人',
feishuQARobot: '飞书问答机器人',
dingtalkQARobot: '钉钉问答机器人',
wecomQARobot: '企业微信问答机器人',
wecomServiceRobot: '企业微信客服机器人',
discordQARobot: 'Discord 问答机器人',
docVersionHistory: '文档历史版本管理',
apiCall: 'API 调用',
sourceCode: '项目源码',
};
/**
*
*/
export const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
[FeatureStatus.NOT_SUPPORTED]: '不支持',
[FeatureStatus.SUPPORTED]: '支持',
[FeatureStatus.BASIC]: '基础配置',
[FeatureStatus.ADVANCED]: '高级配置',
};
/**
*
*/
export function getFeatureValue<K extends keyof VersionInfo['features']>(
edition: ConstsLicenseEdition,
key: K,
): VersionInfo['features'][K] {
return (
VERSION_INFO[edition] ||
VERSION_INFO[ConstsLicenseEdition.LicenseEditionFree]
).features[key];
}

View File

@ -1,3 +1,8 @@
export { useBindCaptcha } from './useBindCaptcha'; export { useBindCaptcha } from './useBindCaptcha';
export { useCommitPendingInput } from './useCommitPendingInput'; export { useCommitPendingInput } from './useCommitPendingInput';
export { useURLSearchParams } from './useURLSearchParams'; export { useURLSearchParams } from './useURLSearchParams';
export {
useFeatureValue,
useFeatureValueSupported,
useVersionInfo,
} from './useVersionFeature';

View File

@ -0,0 +1,34 @@
import {
FeatureStatus,
VersionInfoMap,
VersionInfo,
getFeatureValue,
} from '@/constant/version';
import { ConstsLicenseEdition } from '@/request/types';
import { useAppSelector } from '@/store';
export const useFeatureValue = <K extends keyof VersionInfo['features']>(
key: K,
): VersionInfo['features'][K] => {
const { license } = useAppSelector(state => state.config);
return getFeatureValue(license.edition!, key);
};
export const useFeatureValueSupported = (
key: keyof VersionInfo['features'],
) => {
const { license } = useAppSelector(state => state.config);
return (
getFeatureValue(license.edition!, key) === FeatureStatus.SUPPORTED ||
getFeatureValue(license.edition!, key) === FeatureStatus.ADVANCED
);
};
export const useVersionInfo = () => {
const { license } = useAppSelector(state => state.config);
return (
VersionInfoMap[
license.edition ?? ConstsLicenseEdition.LicenseEditionFree
] || VersionInfoMap[ConstsLicenseEdition.LicenseEditionFree]
);
};

View File

@ -8,6 +8,8 @@ import { styled } from '@mui/material/styles';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import DocModal from './DocModal'; import DocModal from './DocModal';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { useURLSearchParams } from '@/hooks'; import { useURLSearchParams } from '@/hooks';
import { import {
@ -46,7 +48,7 @@ const statusColorMap = {
} as const; } as const;
export default function ContributionPage() { export default function ContributionPage() {
const { kb_id = '', kbDetail } = useAppSelector(state => state.config); const { kb_id = '', license } = useAppSelector(state => state.config);
const [searchParams, setSearchParams] = useURLSearchParams(); const [searchParams, setSearchParams] = useURLSearchParams();
const page = Number(searchParams.get('page') || '1'); const page = Number(searchParams.get('page') || '1');
const pageSize = Number(searchParams.get('page_size') || '20'); const pageSize = Number(searchParams.get('page_size') || '20');
@ -283,111 +285,114 @@ export default function ContributionPage() {
}; };
useEffect(() => { useEffect(() => {
if (kb_id) getData(); if (kb_id && PROFESSION_VERSION_PERMISSION.includes(license.edition!))
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, nodeNameParam, authNameParam, kb_id]); }, [page, pageSize, nodeNameParam, authNameParam, kb_id, license.edition]);
return ( return (
<Card> <Card>
<Stack <VersionMask permission={PROFESSION_VERSION_PERMISSION}>
direction='row' <Stack
alignItems={'center'} direction='row'
justifyContent={'space-between'} alignItems={'center'}
sx={{ p: 2 }} justifyContent={'space-between'}
> sx={{ p: 2 }}
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}> >
<TextField <StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
fullWidth <TextField
size='small' fullWidth
label='文档' size='small'
value={searchDoc} label='文档'
onKeyUp={e => { value={searchDoc}
if (e.key === 'Enter') { onKeyUp={e => {
setSearchParams({ node_name: searchDoc || '', page: '1' }); if (e.key === 'Enter') {
} setSearchParams({ node_name: searchDoc || '', page: '1' });
}} }
onBlur={e => { }}
setSearchParams({ node_name: e.target.value, page: '1' }); onBlur={e => {
}} setSearchParams({ node_name: e.target.value, page: '1' });
onChange={e => setSearchDoc(e.target.value)} }}
sx={{ width: 200 }} onChange={e => setSearchDoc(e.target.value)}
/> sx={{ width: 200 }}
<TextField />
fullWidth <TextField
size='small' fullWidth
label='用户' size='small'
value={searchUser} label='用户'
onKeyUp={e => { value={searchUser}
if (e.key === 'Enter') { onKeyUp={e => {
setSearchParams({ auth_name: searchUser || '', page: '1' }); if (e.key === 'Enter') {
} setSearchParams({ auth_name: searchUser || '', page: '1' });
}} }
onBlur={e => { }}
setSearchParams({ auth_name: e.target.value, page: '1' }); onBlur={e => {
}} setSearchParams({ auth_name: e.target.value, page: '1' });
onChange={e => setSearchUser(e.target.value)} }}
sx={{ width: 200 }} onChange={e => setSearchUser(e.target.value)}
/> sx={{ width: 200 }}
</StyledSearchRow> />
</Stack> </StyledSearchRow>
<Table </Stack>
columns={columns} <Table
dataSource={data} columns={columns}
rowKey='id' dataSource={data}
height='calc(100vh - 148px)' rowKey='id'
size='small' height='calc(100vh - 148px)'
sx={{ size='small'
overflow: 'hidden', sx={{
...tableSx, overflow: 'hidden',
'.MuiTableContainer-root': { ...tableSx,
height: 'calc(100vh - 148px - 70px)', '.MuiTableContainer-root': {
}, height: 'calc(100vh - 148px - 70px)',
}}
pagination={{
total,
page,
pageSize,
onChange: (page, pageSize) => {
setSearchParams({
page: String(page),
page_size: String(pageSize),
});
},
}}
PaginationProps={{
sx: {
borderTop: '1px solid',
borderColor: 'divider',
p: 2,
'.MuiSelect-root': {
width: 100,
}, },
}, }}
}} pagination={{
/> total,
page,
pageSize,
onChange: (page, pageSize) => {
setSearchParams({
page: String(page),
page_size: String(pageSize),
});
},
}}
PaginationProps={{
sx: {
borderTop: '1px solid',
borderColor: 'divider',
p: 2,
'.MuiSelect-root': {
width: 100,
},
},
}}
/>
{previewRow?.meta?.content_type === 'md' ? ( {previewRow?.meta?.content_type === 'md' ? (
<MarkdownPreviewModal <MarkdownPreviewModal
open={open} open={open}
row={previewRow} row={previewRow}
onClose={closeDialog} onClose={closeDialog}
onAccept={handleAccept} onAccept={handleAccept}
onReject={handleReject} onReject={handleReject}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/> />
) : ( </VersionMask>
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
</Card> </Card>
); );
} }

View File

@ -2,10 +2,10 @@ import { ChatConversationPair } from '@/api';
import { getApiV1ConversationDetail } from '@/request/Conversation'; import { getApiV1ConversationDetail } from '@/request/Conversation';
import { DomainConversationDetailResp } from '@/request/types'; import { DomainConversationDetailResp } from '@/request/types';
import Avatar from '@/components/Avatar'; import Avatar from '@/components/Avatar';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import Card from '@/components/Card'; import Card from '@/components/Card';
import MarkDown from '@/components/MarkDown'; import MarkDown from '@/components/MarkDown';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { import {
Accordion, Accordion,
AccordionDetails, AccordionDetails,
@ -13,10 +13,169 @@ import {
Box, Box,
Stack, Stack,
useTheme, useTheme,
styled,
alpha,
Typography,
} from '@mui/material'; } from '@mui/material';
import { Ellipsis, Icon, Modal } from '@ctzhian/ui'; import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const handleThinkingContent = (content: string) => {
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
const thinkMatches = [];
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkMatches.push(match[1]);
}
let answerContent = content.replace(/<think>[\s\S]*?<\/think>/g, '');
answerContent = answerContent.replace(/<think>[\s\S]*$/, '');
return {
thinkingContent: thinkMatches.join(''),
answerContent: answerContent,
};
};
export const StyledConversationItem = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
// 聊天气泡相关组件
export const StyledUserBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-end',
maxWidth: '75%',
padding: theme.spacing(1, 2),
borderRadius: '10px 10px 0px 10px',
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
fontSize: 14,
wordBreak: 'break-word',
}));
export const StyledAiBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-start',
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: theme.spacing(3),
}));
export const StyledAiBubbleContent = styled(Box)(() => ({
wordBreak: 'break-word',
}));
// 对话相关组件
export const StyledAccordion = styled(Accordion)(() => ({
padding: 0,
border: 'none',
'&:before': {
content: '""',
height: 0,
},
background: 'transparent',
backgroundImage: 'none',
}));
export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
userSelect: 'text',
borderRadius: '10px',
backgroundColor: theme.palette.background.paper3,
border: '1px solid',
borderColor: theme.palette.divider,
}));
export const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: theme.spacing(2),
borderTop: 'none',
}));
export const StyledQuestionText = styled(Box)(() => ({
fontWeight: '700',
fontSize: 16,
lineHeight: '24px',
wordBreak: 'break-all',
}));
// 搜索结果相关组件
export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({
backgroundImage: 'none',
background: 'transparent',
border: 'none',
padding: 0,
}));
export const StyledChunkAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledChunkAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
}),
);
export const StyledChunkItem = styled(Box)(({ theme }) => ({
cursor: 'pointer',
'&:hover': {
'.hover-primary': {
color: theme.palette.primary.main,
},
},
}));
// 思考过程相关组件
export const StyledThinkingAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: 'transparent',
border: 'none',
padding: 0,
paddingBottom: theme.spacing(2),
'&:before': {
content: '""',
height: 0,
},
}));
export const StyledThinkingAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledThinkingAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
'.markdown-body': {
opacity: 0.75,
fontSize: 12,
},
}),
);
const Detail = ({ const Detail = ({
id, id,
open, open,
@ -55,7 +214,11 @@ const Detail = ({
}; };
} else if (message.role === 'assistant') { } else if (message.role === 'assistant') {
if (currentPair.user) { if (currentPair.user) {
currentPair.assistant = message.content; const { thinkingContent, answerContent } = handleThinkingContent(
message.content || '',
);
currentPair.assistant = answerContent;
currentPair.thinking_content = thinkingContent;
currentPair.created_at = message.created_at; currentPair.created_at = message.created_at;
// @ts-expect-error 类型不兼容 // @ts-expect-error 类型不兼容
currentPair.info = message.info; currentPair.info = message.info;
@ -167,26 +330,43 @@ const Detail = ({
<Stack gap={2}> <Stack gap={2}>
{conversations && {conversations &&
conversations.map((item, index) => ( conversations.map((item, index) => (
<Box key={index}> <StyledConversationItem key={index}>
<Accordion defaultExpanded={true}> {/* 用户问题气泡 - 右对齐 */}
<AccordionSummary <StyledUserBubble>{item.user}</StyledUserBubble>
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
sx={{ {/* AI回答气泡 - 左对齐 */}
userSelect: 'text', <StyledAiBubble>
backgroundColor: 'background.paper3', {/* 思考过程 */}
fontSize: '18px', {!!item.thinking_content && (
fontWeight: 'bold', <StyledThinkingAccordion defaultExpanded>
}} <StyledThinkingAccordionSummary
> expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
{item.user} >
</AccordionSummary> <Stack direction='row' alignItems='center' gap={1}>
<AccordionDetails> <Typography
<MarkDown variant='body2'
content={item.assistant || '未查询到回答内容'} sx={theme => ({
/> fontSize: 12,
</AccordionDetails> color: alpha(theme.palette.text.primary, 0.5),
</Accordion> })}
</Box> >
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown content={item.thinking_content || ''} />
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
<MarkDown content={item.assistant} />
</StyledAiBubbleContent>
</StyledAiBubble>
</StyledConversationItem>
))} ))}
</Stack> </Stack>
</Box> </Box>

View File

@ -31,6 +31,8 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
interface DocPropertiesModalProps { interface DocPropertiesModalProps {
open: boolean; open: boolean;
@ -40,8 +42,6 @@ interface DocPropertiesModalProps {
data: DomainNodeListItemResp[]; data: DomainNodeListItemResp[];
} }
const tips = '(企业版可用)';
const StyledText = styled('div')(({ theme }) => ({ const StyledText = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontSize: 16, fontSize: 16,
@ -53,7 +53,12 @@ const PER_OPTIONS = [
value: ConstsNodeAccessPerm.NodeAccessPermOpen, value: ConstsNodeAccessPerm.NodeAccessPermOpen,
}, },
{ {
label: '部分开放', label: (
<Stack direction={'row'} alignItems={'center'}>
<span></span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: ConstsNodeAccessPerm.NodeAccessPermPartial, value: ConstsNodeAccessPerm.NodeAccessPermPartial,
}, },
{ {
@ -128,13 +133,13 @@ const DocPropertiesModal = ({
visitable: values.visitable as ConstsNodeAccessPerm, visitable: values.visitable as ConstsNodeAccessPerm,
visible: values.visible as ConstsNodeAccessPerm, visible: values.visible as ConstsNodeAccessPerm,
}, },
answerable_groups: isEnterprise answerable_groups: isBusiness
? values.answerable_groups.map(item => item.id!) ? values.answerable_groups.map(item => item.id!)
: undefined, : undefined,
visitable_groups: isEnterprise visitable_groups: isBusiness
? values.visitable_groups.map(item => item.id!) ? values.visitable_groups.map(item => item.id!)
: undefined, : undefined,
visible_groups: isEnterprise visible_groups: isBusiness
? values.visible_groups.map(item => item.id!) ? values.visible_groups.map(item => item.id!)
: undefined, : undefined,
}), }),
@ -153,15 +158,15 @@ const DocPropertiesModal = ({
}); });
}); });
const isEnterprise = useMemo(() => { const isBusiness = useMemo(() => {
return license.edition === 2; return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license]);
const tree = filterEmptyFolders(convertToTree(data)); const tree = filterEmptyFolders(convertToTree(data));
useEffect(() => { useEffect(() => {
if (open && data) { if (open && data) {
if (isEnterprise) { if (isBusiness) {
getApiProV1AuthGroupList({ getApiProV1AuthGroupList({
kb_id: kb_id!, kb_id: kb_id!,
page: 1, page: 1,
@ -206,7 +211,7 @@ const DocPropertiesModal = ({
); );
}); });
} }
}, [open, data, isEnterprise]); }, [open, data, isBusiness]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -302,22 +307,15 @@ const DocPropertiesModal = ({
name='answerable' name='answerable'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<RadioGroup row {...field}> <RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => ( {PER_OPTIONS.map(option => (
<FormControlLabel <FormControlLabel
key={option.value} key={option.value}
value={option.value} value={option.value}
control={<Radio size='small' />} control={<Radio size='small' />}
label={ label={option.label}
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
disabled={ disabled={
!isEnterprise && !isBusiness &&
option.value === option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial ConstsNodeAccessPerm.NodeAccessPermPartial
} }
@ -359,22 +357,15 @@ const DocPropertiesModal = ({
name='visitable' name='visitable'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<RadioGroup row {...field}> <RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => ( {PER_OPTIONS.map(option => (
<FormControlLabel <FormControlLabel
key={option.value} key={option.value}
value={option.value} value={option.value}
control={<Radio size='small' />} control={<Radio size='small' />}
label={ label={option.label}
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
disabled={ disabled={
!isEnterprise && !isBusiness &&
option.value === option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial ConstsNodeAccessPerm.NodeAccessPermPartial
} }
@ -416,22 +407,15 @@ const DocPropertiesModal = ({
name='visible' name='visible'
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<RadioGroup row {...field}> <RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => ( {PER_OPTIONS.map(option => (
<FormControlLabel <FormControlLabel
key={option.value} key={option.value}
value={option.value} value={option.value}
control={<Radio size='small' />} control={<Radio size='small' />}
label={ label={option.label}
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
disabled={ disabled={
!isEnterprise && !isBusiness &&
option.value === option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial ConstsNodeAccessPerm.NodeAccessPermPartial
} }

View File

@ -22,6 +22,8 @@ import { useNavigate, useOutletContext } from 'react-router-dom';
import { WrapContext } from '..'; import { WrapContext } from '..';
import DocAddByCustomText from '../../component/DocAddByCustomText'; import DocAddByCustomText from '../../component/DocAddByCustomText';
import DocDelete from '../../component/DocDelete'; import DocDelete from '../../component/DocDelete';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
interface HeaderProps { interface HeaderProps {
edit: boolean; edit: boolean;
@ -52,8 +54,8 @@ const Header = ({
const [showSaveTip, setShowSaveTip] = useState(false); const [showSaveTip, setShowSaveTip] = useState(false);
const isEnterprise = useMemo(() => { const isBusiness = useMemo(() => {
return license.edition === 2; return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license]);
const handlePublish = useCallback(() => { const handlePublish = useCallback(() => {
@ -309,6 +311,7 @@ const Header = ({
// }, // },
{ {
key: 'copy', key: 'copy',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>, label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => { onClick: () => {
if (kb_id) { if (kb_id) {
@ -328,26 +331,22 @@ const Header = ({
}, },
{ {
key: 'version', key: 'version',
textSx: { flex: 1 },
label: ( label: (
<StyledMenuSelect disabled={!isEnterprise}> <StyledMenuSelect disabled={!isBusiness}>
{' '}
{!isEnterprise && ( <VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
<Tooltip title='企业版可用' placement='top' arrow>
<InfoIcon
sx={{ color: 'text.secondary', fontSize: 14 }}
/>
</Tooltip>
)}
</StyledMenuSelect> </StyledMenuSelect>
), ),
onClick: () => { onClick: () => {
if (isEnterprise) { if (isBusiness) {
navigate(`/doc/editor/history/${detail.id}`); navigate(`/doc/editor/history/${detail.id}`);
} }
}, },
}, },
{ {
key: 'rename', key: 'rename',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>, label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => { onClick: () => {
setRenameOpen(true); setRenameOpen(true);
@ -355,6 +354,7 @@ const Header = ({
}, },
{ {
key: 'delete', key: 'delete',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>, label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => { onClick: () => {
setDelOpen(true); setDelOpen(true);
@ -566,7 +566,7 @@ const StyledMenuSelect = styled('div')<{ disabled?: boolean }>(
padding: theme.spacing(0, 2), padding: theme.spacing(0, 2),
lineHeight: '40px', lineHeight: '40px',
height: 40, height: 40,
width: 106, minWidth: 106,
borderRadius: '5px', borderRadius: '5px',
color: disabled ? theme.palette.text.secondary : theme.palette.text.primary, color: disabled ? theme.palette.text.secondary : theme.palette.text.primary,
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',

View File

@ -29,6 +29,7 @@ import Header from './Header';
import Summary from './Summary'; import Summary from './Summary';
import Toc from './Toc'; import Toc from './Toc';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
interface WrapProps { interface WrapProps {
detail: V1NodeDetailResp; detail: V1NodeDetailResp;
@ -72,8 +73,8 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
emoji: defaultDetail.meta?.emoji || '', emoji: defaultDetail.meta?.emoji || '',
}); });
const isEnterprise = useMemo(() => { const isBusiness = useMemo(() => {
return license.edition === 2; return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license]);
const debouncedUpdateSummary = useCallback( const debouncedUpdateSummary = useCallback(
@ -383,7 +384,7 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
</Stack> </Stack>
</Tooltip> </Tooltip>
)} )}
<Tooltip arrow title={isEnterprise ? '查看历史版本' : ''}> <Tooltip arrow title={isBusiness ? '查看历史版本' : ''}>
<Stack <Stack
direction={'row'} direction={'row'}
alignItems={'center'} alignItems={'center'}
@ -391,13 +392,13 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
sx={{ sx={{
fontSize: 12, fontSize: 12,
color: 'text.tertiary', color: 'text.tertiary',
cursor: isEnterprise ? 'pointer' : 'text', cursor: isBusiness ? 'pointer' : 'text',
':hover': { ':hover': {
color: isEnterprise ? 'primary.main' : 'text.tertiary', color: isBusiness ? 'primary.main' : 'text.tertiary',
}, },
}} }}
onClick={() => { onClick={() => {
if (isEnterprise) { if (isBusiness) {
navigate(`/doc/editor/history/${defaultDetail.id}`); navigate(`/doc/editor/history/${defaultDetail.id}`);
} }
}} }}

View File

@ -25,6 +25,7 @@ import {
IconButton, IconButton,
Stack, Stack,
useTheme, useTheme,
ButtonBase,
} from '@mui/material'; } from '@mui/material';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import VersionPublish from '../release/components/VersionPublish'; import VersionPublish from '../release/components/VersionPublish';
@ -418,15 +419,19 @@ const Content = () => {
> >
{publish.unpublished} / {publish.unpublished} /
</Box> </Box>
<Button <ButtonBase
size='small' disableRipple
sx={{ minWidth: 0, p: 0, fontSize: 12 }} sx={{
fontSize: 12,
fontWeight: 400,
color: 'primary.main',
}}
onClick={() => { onClick={() => {
setPublishOpen(true); setPublishOpen(true);
}} }}
> >
</Button> </ButtonBase>
</> </>
)} )}
{ragReStartCount > 0 && ( {ragReStartCount > 0 && (
@ -441,15 +446,19 @@ const Content = () => {
> >
{ragReStartCount} {ragReStartCount}
</Box> </Box>
<Button <ButtonBase
size='small' disableRipple
sx={{ minWidth: 0, p: 0, fontSize: 12 }} sx={{
fontSize: 12,
fontWeight: 400,
color: 'primary.main',
}}
onClick={() => { onClick={() => {
setRagOpen(true); setRagOpen(true);
}} }}
> >
</Button> </ButtonBase>
</> </>
)} )}
</Stack> </Stack>

View File

@ -28,6 +28,7 @@ import {
ButtonBase, ButtonBase,
} from '@mui/material'; } from '@mui/material';
import { Ellipsis, Table, Modal, Icon, message } from '@ctzhian/ui'; import { Ellipsis, Table, Modal, Icon, message } from '@ctzhian/ui';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
@ -162,8 +163,8 @@ const Comments = ({
useState<DomainWebAppCommentSettings | null>(null); useState<DomainWebAppCommentSettings | null>(null);
const isEnableReview = useMemo(() => { const isEnableReview = useMemo(() => {
return !!(license.edition === 1 || license.edition === 2); return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license.edition]);
useEffect(() => { useEffect(() => {
setShowCommentsFilter(isEnableReview); setShowCommentsFilter(isEnableReview);

View File

@ -3,14 +3,18 @@ import { getApiV1ConversationMessageDetail } from '@/request';
import MarkDown from '@/components/MarkDown'; import MarkDown from '@/components/MarkDown';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { import { Box, Stack, Typography, alpha } from '@mui/material';
Accordion, import { Ellipsis, Modal } from '@ctzhian/ui';
AccordionDetails,
AccordionSummary,
Box,
} from '@mui/material';
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import {
StyledConversationItem,
StyledUserBubble,
StyledAiBubble,
StyledThinkingAccordion,
StyledThinkingAccordionSummary,
StyledThinkingAccordionDetails,
StyledAiBubbleContent,
} from '../conversation/Detail';
const Detail = ({ const Detail = ({
id, id,
@ -36,6 +40,7 @@ const Detail = ({
user: data.question, user: data.question,
assistant: res.content!, assistant: res.content!,
created_at: res.created_at!, created_at: res.created_at!,
thinking_content: '',
}); });
}); });
} }
@ -62,24 +67,43 @@ const Detail = ({
> >
<Box sx={{ fontSize: 14 }}> <Box sx={{ fontSize: 14 }}>
<Box> <Box>
<Accordion defaultExpanded={true}> <StyledConversationItem>
<AccordionSummary {/* 用户问题气泡 - 右对齐 */}
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />} <StyledUserBubble>{conversations?.user}</StyledUserBubble>
sx={{
userSelect: 'text', {/* AI回答气泡 - 左对齐 */}
backgroundColor: 'background.paper3', <StyledAiBubble>
fontSize: '18px', {/* 思考过程 */}
fontWeight: 'bold', {!!conversations?.thinking_content && (
}} <StyledThinkingAccordion defaultExpanded>
> <StyledThinkingAccordionSummary
{conversations?.user} expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
</AccordionSummary> >
<AccordionDetails> <Stack direction='row' alignItems='center' gap={1}>
<MarkDown <Typography
content={conversations?.assistant || '未查询到回答内容'} variant='body2'
/> sx={theme => ({
</AccordionDetails> fontSize: 12,
</Accordion> color: alpha(theme.palette.text.primary, 0.5),
})}
>
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown content={conversations?.thinking_content || ''} />
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
<MarkDown content={conversations?.assistant || ''} />
</StyledAiBubbleContent>
</StyledAiBubble>
</StyledConversationItem>
</Box> </Box>
</Box> </Box>
</Modal> </Modal>

View File

@ -14,6 +14,8 @@ import dayjs from 'dayjs';
import { ColumnType } from '@ctzhian/ui/dist/Table'; import { ColumnType } from '@ctzhian/ui/dist/Table';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { VersionCanUse } from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
interface AddRoleProps { interface AddRoleProps {
open: boolean; open: boolean;
@ -23,7 +25,8 @@ interface AddRoleProps {
} }
const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => { const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
const { kb_id, license } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const { license } = useAppSelector(state => state.config);
const [list, setList] = useState<V1UserListItemResp[]>([]); const [list, setList] = useState<V1UserListItemResp[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string>(''); const [selectedRowKeys, setSelectedRowKeys] = useState<string>('');
@ -31,10 +34,6 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
ConstsUserKBPermission.UserKBPermissionFullControl, ConstsUserKBPermission.UserKBPermissionFullControl,
); );
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const columns: ColumnType<V1UserListItemResp>[] = [ const columns: ColumnType<V1UserListItemResp>[] = [
{ {
title: '', title: '',
@ -119,6 +118,10 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
} }
}, [open]); }, [open]);
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
return ( return (
<Modal <Modal
title='添加 Wiki 站管理员' title='添加 Wiki 站管理员'
@ -209,22 +212,33 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
fullWidth fullWidth
sx={{ height: 52 }} sx={{ height: 52 }}
value={perm} value={perm}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
onChange={e => setPerm(e.target.value as V1KBUserInviteReq['perm'])} onChange={e => setPerm(e.target.value as V1KBUserInviteReq['perm'])}
> >
<MenuItem value={ConstsUserKBPermission.UserKBPermissionFullControl}> <MenuItem value={ConstsUserKBPermission.UserKBPermissionFullControl}>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage} value={ConstsUserKBPermission.UserKBPermissionDocManage}
disabled={!isPro}
> >
{isEnterprise ? '' : '(企业版可用)'} {' '}
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate} value={ConstsUserKBPermission.UserKBPermissionDataOperate}
disabled={!isPro}
> >
{isEnterprise ? '' : '(企业版可用)'} {' '}
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
</Select> </Select>
</FormItem> </FormItem>

View File

@ -1,5 +1,6 @@
import { getApiProV1Prompt, postApiProV1Prompt } from '@/request/pro/Prompt'; import { getApiProV1Prompt, postApiProV1Prompt } from '@/request/pro/Prompt';
import { DomainKnowledgeBaseDetail } from '@/request/types'; import { DomainKnowledgeBaseDetail } from '@/request/types';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { message } from '@ctzhian/ui'; import { message } from '@ctzhian/ui';
import { Box, Slider, TextField } from '@mui/material'; import { Box, Slider, TextField } from '@mui/material';
@ -33,11 +34,12 @@ const CardAI = ({ kb }: CardAIProps) => {
}); });
const isPro = useMemo(() => { const isPro = useMemo(() => {
return license.edition === 1 || license.edition === 2; return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license]);
useEffect(() => { useEffect(() => {
if (!kb.id || !isPro) return; if (!kb.id || !PROFESSION_VERSION_PERMISSION.includes(license.edition!))
return;
getApiProV1Prompt({ kb_id: kb.id! }).then(res => { getApiProV1Prompt({ kb_id: kb.id! }).then(res => {
setValue('content', res.content || ''); setValue('content', res.content || '');
}); });
@ -54,7 +56,7 @@ const CardAI = ({ kb }: CardAIProps) => {
<SettingCardItem title='智能问答' isEdit={isEdit} onSubmit={onSubmit}> <SettingCardItem title='智能问答' isEdit={isEdit} onSubmit={onSubmit}>
<FormItem <FormItem
vertical vertical
tooltip={!isPro && '联创版和企业版可用'} permission={PROFESSION_VERSION_PERMISSION}
extra={ extra={
<Box <Box
sx={{ sx={{

View File

@ -29,6 +29,8 @@ import { ColumnType } from '@ctzhian/ui/dist/Table';
import { useEffect, useMemo, useState, useRef } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
import { SettingCardItem, FormItem } from './Common'; import { SettingCardItem, FormItem } from './Common';
interface CardAuthProps { interface CardAuthProps {
@ -114,7 +116,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
}), }),
value.enabled === '2' && value.enabled === '2' &&
source_type !== EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword source_type !== EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword
? isPro ? isBusiness
? postApiProV1AuthSet({ ? postApiProV1AuthSet({
kb_id, kb_id,
source_type: value.source_type as ConstsSourceType, source_type: value.source_type as ConstsSourceType,
@ -157,25 +159,18 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
}); });
}); });
const isPro = useMemo(() => { const isBusiness = useMemo(() => {
return license.edition === 1 || license.edition === 2; return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]); }, [license]);
useEffect(() => { useEffect(() => {
const source_type = isPro const source_type = isBusiness
? kb.access_settings?.source_type || ? kb.access_settings?.source_type ||
EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword
: kb.access_settings?.source_type === : EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword;
EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub
? EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub
: EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword;
setValue('source_type', source_type); setValue('source_type', source_type);
sourceTypeRef.current = source_type; sourceTypeRef.current = source_type;
}, [kb, isPro]); }, [kb, isBusiness]);
useEffect(() => { useEffect(() => {
if (kb.access_settings?.simple_auth) { if (kb.access_settings?.simple_auth) {
@ -191,7 +186,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
}, [kb]); }, [kb]);
const getAuth = () => { const getAuth = () => {
if (isPro) { if (isBusiness) {
getApiProV1AuthGet({ getApiProV1AuthGet({
kb_id, kb_id,
source_type: source_type as ConstsSourceType, source_type: source_type as ConstsSourceType,
@ -236,7 +231,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
useEffect(() => { useEffect(() => {
if (!kb_id || enabled !== '2') return; if (!kb_id || enabled !== '2') return;
getAuth(); getAuth();
}, [kb_id, isPro, source_type, enabled]); }, [kb_id, isBusiness, source_type, enabled]);
const columns: ColumnType<GithubComChaitinPandaWikiProApiAuthV1AuthItem>[] = [ const columns: ColumnType<GithubComChaitinPandaWikiProApiAuthV1AuthItem>[] = [
{ {
@ -875,8 +870,18 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
field.onChange(e.target.value); field.onChange(e.target.value);
setIsEdit(true); setIsEdit(true);
}} }}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
fullWidth fullWidth
sx={{ height: 52 }} sx={{
height: 52,
}}
> >
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword}
@ -885,44 +890,52 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeDingTalk} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeDingTalk}
disabled={!isPro} disabled={!isBusiness}
> >
{isPro ? '' : tips} {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeFeishu} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeFeishu}
disabled={!isPro} disabled={!isBusiness}
> >
{isPro ? '' : tips} {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeWeCom} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeWeCom}
disabled={!isPro} disabled={!isBusiness}
> >
{isPro ? '' : tips} {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeOAuth} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeOAuth}
disabled={!isPro} disabled={!isBusiness}
> >
OAuth {isPro ? '' : tips} OAuth {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeCAS} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeCAS}
disabled={!isPro} disabled={!isBusiness}
> >
CAS {isPro ? '' : tips} CAS {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeLDAP} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeLDAP}
disabled={!isPro} disabled={!isBusiness}
> >
LDAP {isPro ? '' : tips} LDAP {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
<MenuItem <MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub} value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub}
disabled={!isBusiness}
> >
GitHub GitHub {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem> </MenuItem>
</Select> </Select>
)} )}

View File

@ -3,7 +3,7 @@ import {
DomainKnowledgeBaseDetail, DomainKnowledgeBaseDetail,
} from '@/request/types'; } from '@/request/types';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import InfoIcon from '@mui/icons-material/Info'; import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { import {
Box, Box,
Chip, Chip,
@ -12,7 +12,6 @@ import {
RadioGroup, RadioGroup,
styled, styled,
TextField, TextField,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import { getApiV1AppDetail, putApiV1App } from '@/request/App'; import { getApiV1AppDetail, putApiV1App } from '@/request/App';
@ -37,7 +36,7 @@ const DocumentComments = ({
data: DomainAppDetailResp; data: DomainAppDetailResp;
refresh: () => void; refresh: () => void;
}) => { }) => {
const { license, kb_id } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const { control, handleSubmit, setValue } = useForm({ const { control, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
@ -57,8 +56,6 @@ const DocumentComments = ({
); );
}, [data]); }, [data]);
const isPro = license.edition === 1 || license.edition === 2;
const onSubmit = handleSubmit(formData => { const onSubmit = handleSubmit(formData => {
putApiV1App( putApiV1App(
{ id: data.id! }, { id: data.id! },
@ -108,7 +105,7 @@ const DocumentComments = ({
)} )}
/> />
</FormItem> </FormItem>
<FormItem label='评论审核' tooltip={!isPro && '联创版和企业版可用'}> <FormItem label='评论审核' permission={PROFESSION_VERSION_PERMISSION}>
<Controller <Controller
control={control} control={control}
name='moderation_enable' name='moderation_enable'
@ -116,7 +113,6 @@ const DocumentComments = ({
<RadioGroup <RadioGroup
row row
{...field} {...field}
value={isPro ? field.value : undefined}
onChange={e => { onChange={e => {
setIsEdit(true); setIsEdit(true);
field.onChange(+e.target.value as 1 | 0); field.onChange(+e.target.value as 1 | 0);
@ -124,12 +120,12 @@ const DocumentComments = ({
> >
<FormControlLabel <FormControlLabel
value={1} value={1}
control={<Radio size='small' disabled={!isPro} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={0} value={0}
control={<Radio size='small' disabled={!isPro} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
</RadioGroup> </RadioGroup>
@ -150,7 +146,7 @@ const AIQuestion = ({
refresh: () => void; refresh: () => void;
}) => { }) => {
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const { kb_id, license } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const { control, handleSubmit, setValue } = useForm({ const { control, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
is_enabled: true, is_enabled: true,
@ -159,7 +155,6 @@ const AIQuestion = ({
}, },
}); });
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const isEnterprise = license.edition === 2;
const onSubmit = handleSubmit(formData => { const onSubmit = handleSubmit(formData => {
putApiV1App( putApiV1App(
@ -273,7 +268,7 @@ const AIQuestion = ({
)} )}
/>{' '} />{' '}
</FormItem> </FormItem>
<FormItem label='免责声明' tooltip={!isEnterprise && '企业版可用'}> <FormItem label='免责声明' permission={PROFESSION_VERSION_PERMISSION}>
<Controller <Controller
control={control} control={control}
name='disclaimer' name='disclaimer'
@ -282,7 +277,6 @@ const AIQuestion = ({
{...field} {...field}
fullWidth fullWidth
value={field.value || ''} value={field.value || ''}
disabled={!isEnterprise}
placeholder='请输入免责声明' placeholder='请输入免责声明'
onChange={e => { onChange={e => {
setIsEdit(true); setIsEdit(true);
@ -304,7 +298,7 @@ const DocumentContribution = ({
refresh: () => void; refresh: () => void;
}) => { }) => {
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const { license, kb_id } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const { control, handleSubmit, setValue } = useForm({ const { control, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
is_enable: false, is_enable: false,
@ -330,7 +324,6 @@ const DocumentContribution = ({
}); });
}); });
const isPro = license.edition === 1 || license.edition === 2;
useEffect(() => { useEffect(() => {
setValue( setValue(
'is_enable', 'is_enable',
@ -340,21 +333,8 @@ const DocumentContribution = ({
}, [data]); }, [data]);
return ( return (
<SettingCardItem <SettingCardItem title='文档贡献' isEdit={isEdit} onSubmit={onSubmit}>
title={ <FormItem label='文档贡献' permission={PROFESSION_VERSION_PERMISSION}>
<>
{!isPro && (
<Tooltip title='联创版和企业版可用' placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14 }} />
</Tooltip>
)}
</>
}
isEdit={isEdit}
onSubmit={onSubmit}
>
<FormItem label='文档贡献'>
<Controller <Controller
control={control} control={control}
name='is_enable' name='is_enable'
@ -362,7 +342,7 @@ const DocumentContribution = ({
<RadioGroup <RadioGroup
row row
{...field} {...field}
value={isPro ? field.value : undefined} value={field.value}
onChange={e => { onChange={e => {
setIsEdit(true); setIsEdit(true);
field.onChange(e.target.value === 'true'); field.onChange(e.target.value === 'true');
@ -370,12 +350,12 @@ const DocumentContribution = ({
> >
<FormControlLabel <FormControlLabel
value={true} value={true}
control={<Radio size='small' disabled={!isPro} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={false} value={false}
control={<Radio size='small' disabled={!isPro} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
</RadioGroup> </RadioGroup>

View File

@ -38,6 +38,10 @@ import { Controller, useForm } from 'react-hook-form';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import AddRole from './AddRole'; import AddRole from './AddRole';
import { Form, FormItem, SettingCardItem } from './Common'; import { Form, FormItem, SettingCardItem } from './Common';
import {
PROFESSION_VERSION_PERMISSION,
BUSINESS_VERSION_PERMISSION,
} from '@/constant/version';
type ApiTokenPermission = type ApiTokenPermission =
GithubComChaitinPandaWikiProApiTokenV1CreateAPITokenReq['permission']; GithubComChaitinPandaWikiProApiTokenV1CreateAPITokenReq['permission'];
@ -69,8 +73,8 @@ const ApiToken = () => {
perm: ConstsUserKBPermission.UserKBPermissionFullControl, perm: ConstsUserKBPermission.UserKBPermissionFullControl,
}, },
}); });
const isEnterprise = useMemo(() => { const isBusiness = useMemo(() => {
return license.edition === 2; return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license]);
const onDeleteApiToken = (id: string, name: string) => { const onDeleteApiToken = (id: string, name: string) => {
@ -131,9 +135,9 @@ const ApiToken = () => {
}; };
useEffect(() => { useEffect(() => {
if (!kb_id) return; if (!kb_id || !isBusiness) return;
getApiTokenList(); getApiTokenList();
}, [kb_id]); }, [kb_id, isBusiness]);
useEffect(() => { useEffect(() => {
if (!addOpen) reset(); if (!addOpen) reset();
@ -142,27 +146,17 @@ const ApiToken = () => {
return ( return (
<SettingCardItem <SettingCardItem
title='API Token' title='API Token'
permission={BUSINESS_VERSION_PERMISSION}
extra={ extra={
<Stack direction={'row'} alignItems={'center'}> <Stack direction={'row'} alignItems={'center'}>
<Button <Button
color='primary' color='primary'
size='small' size='small'
disabled={!isEnterprise}
onClick={() => setAddOpen(true)} onClick={() => setAddOpen(true)}
sx={{ textTransform: 'none' }} sx={{ textTransform: 'none' }}
> >
API Token API Token
</Button> </Button>
<Tooltip title={'企业版可用'} placement='top' arrow>
<InfoIcon
sx={{
color: 'text.secondary',
fontSize: 14,
display: !isEnterprise ? 'block' : 'none',
}}
/>
</Tooltip>
</Stack> </Stack>
} }
> >
@ -232,7 +226,7 @@ const ApiToken = () => {
size='small' size='small'
sx={{ width: 120 }} sx={{ width: 120 }}
value={it.permission} value={it.permission}
disabled={!isEnterprise || user.role !== 'admin'} disabled={!isBusiness || user.role !== 'admin'}
onChange={e => onChange={e =>
onUpdateApiToken(it.id!, e.target.value as ApiTokenPermission) onUpdateApiToken(it.id!, e.target.value as ApiTokenPermission)
} }
@ -259,7 +253,7 @@ const ApiToken = () => {
kbDetail?.perm !== kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl ConstsUserKBPermission.UserKBPermissionFullControl
? '权限不足' ? '权限不足'
: '业版可用' : '业版可用'
} }
placement='top' placement='top'
arrow arrow
@ -270,7 +264,7 @@ const ApiToken = () => {
fontSize: 14, fontSize: 14,
ml: 1, ml: 1,
visibility: visibility:
!isEnterprise || !isBusiness ||
kbDetail?.perm !== kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl ConstsUserKBPermission.UserKBPermissionFullControl
? 'visible' ? 'visible'
@ -285,13 +279,13 @@ const ApiToken = () => {
type='icon-icon_tool_close' type='icon-icon_tool_close'
sx={{ sx={{
cursor: cursor:
!isEnterprise || !isBusiness ||
kbDetail?.perm !== kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl ConstsUserKBPermission.UserKBPermissionFullControl
? 'not-allowed' ? 'not-allowed'
: 'pointer', : 'pointer',
color: color:
!isEnterprise || !isBusiness ||
kbDetail?.perm !== kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl ConstsUserKBPermission.UserKBPermissionFullControl
? 'text.disabled' ? 'text.disabled'
@ -299,7 +293,7 @@ const ApiToken = () => {
}} }}
onClick={() => { onClick={() => {
if ( if (
!isEnterprise || !isBusiness ||
kbDetail?.perm !== kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl ConstsUserKBPermission.UserKBPermissionFullControl
) )
@ -367,17 +361,16 @@ const ApiToken = () => {
> >
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage} value={ConstsUserKBPermission.UserKBPermissionDocManage}
> >
{isEnterprise ? '' : '(企业版可用)'}
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate} value={ConstsUserKBPermission.UserKBPermissionDataOperate}
> >
{isEnterprise ? '' : '(企业版可用)'}
</MenuItem> </MenuItem>
</Select> </Select>
); );
@ -405,9 +398,9 @@ const CardKB = () => {
}); });
}; };
const isEnterprise = useMemo(() => { const isPro = useMemo(() => {
return license.edition === 2; return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license]); }, [license.edition]);
useEffect(() => { useEffect(() => {
if (!kb_id) return; if (!kb_id) return;
@ -513,7 +506,7 @@ const CardKB = () => {
size='small' size='small'
sx={{ width: 180 }} sx={{ width: 180 }}
value={it.perms} value={it.perms}
disabled={!isEnterprise || it.role === 'admin'} disabled={!isPro || it.role === 'admin'}
onChange={e => onChange={e =>
onUpdateUserPermission( onUpdateUserPermission(
it.id!, it.id!,
@ -542,7 +535,7 @@ const CardKB = () => {
title={ title={
it.role === 'admin' it.role === 'admin'
? '超级管理员不可被修改权限' ? '超级管理员不可被修改权限'
: '业版可用' : '业版可用'
} }
placement='top' placement='top'
arrow arrow
@ -553,9 +546,7 @@ const CardKB = () => {
fontSize: 14, fontSize: 14,
ml: 1, ml: 1,
visibility: visibility:
!isEnterprise || it.role === 'admin' !isPro || it.role === 'admin' ? 'visible' : 'hidden',
? 'visible'
: 'hidden',
}} }}
/> />
</Tooltip> </Tooltip>

View File

@ -9,8 +9,11 @@ import {
} from '@/request/types'; } from '@/request/types';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { Icon, message } from '@ctzhian/ui'; import { Icon, message } from '@ctzhian/ui';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { import {
Box, Box,
Button,
Collapse,
FormControlLabel, FormControlLabel,
Link, Link,
Radio, Radio,
@ -31,6 +34,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const [detail, setDetail] = useState<DomainAppDetailResp | null>(null); const [detail, setDetail] = useState<DomainAppDetailResp | null>(null);
const [widgetConfigOpen, setWidgetConfigOpen] = useState(false);
const [modalConfigOpen, setModalConfigOpen] = useState(false);
const { kb_id } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const { const {
control, control,
@ -43,8 +48,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
defaultValues: { defaultValues: {
is_open: 0, is_open: 0,
theme_mode: 'light', theme_mode: 'light',
btn_style: 'hover_ball',
btn_id: '',
btn_position: 'bottom_right',
disclaimer: '',
btn_text: '', btn_text: '',
btn_logo: '', btn_logo: '',
modal_position: 'follow',
search_mode: 'all',
placeholder: '',
recommend_questions: [] as string[], recommend_questions: [] as string[],
recommend_node_ids: [] as string[], recommend_node_ids: [] as string[],
}, },
@ -54,6 +66,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
const recommend_questions = watch('recommend_questions') || []; const recommend_questions = watch('recommend_questions') || [];
const recommend_node_ids = watch('recommend_node_ids') || []; const recommend_node_ids = watch('recommend_node_ids') || [];
const btn_style = watch('btn_style') || 'hover_ball';
const isCustomButton = btn_style === 'btn_trigger';
const recommendQuestionsField = useCommitPendingInput<string>({ const recommendQuestionsField = useCommitPendingInput<string>({
value: recommend_questions, value: recommend_questions,
@ -87,8 +101,17 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
reset({ reset({
is_open: res.settings?.widget_bot_settings?.is_open ? 1 : 0, is_open: res.settings?.widget_bot_settings?.is_open ? 1 : 0,
theme_mode: res.settings?.widget_bot_settings?.theme_mode || 'light', theme_mode: res.settings?.widget_bot_settings?.theme_mode || 'light',
btn_style: res.settings?.widget_bot_settings?.btn_style || 'hover_ball',
btn_id: res.settings?.widget_bot_settings?.btn_id || '',
btn_position:
res.settings?.widget_bot_settings?.btn_position || 'bottom_right',
btn_text: res.settings?.widget_bot_settings?.btn_text || '在线客服', btn_text: res.settings?.widget_bot_settings?.btn_text || '在线客服',
btn_logo: res.settings?.widget_bot_settings?.btn_logo, btn_logo: res.settings?.widget_bot_settings?.btn_logo || '',
modal_position:
res.settings?.widget_bot_settings?.modal_position || 'follow',
search_mode: res.settings?.widget_bot_settings?.search_mode || 'all',
placeholder: res.settings?.widget_bot_settings?.placeholder || '',
disclaimer: res.settings?.widget_bot_settings?.disclaimer || '',
recommend_questions: recommend_questions:
res.settings?.widget_bot_settings?.recommend_questions || [], res.settings?.widget_bot_settings?.recommend_questions || [],
recommend_node_ids: recommend_node_ids:
@ -108,8 +131,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
widget_bot_settings: { widget_bot_settings: {
is_open: data.is_open === 1 ? true : false, is_open: data.is_open === 1 ? true : false,
theme_mode: data.theme_mode as 'light' | 'dark', theme_mode: data.theme_mode as 'light' | 'dark',
btn_style: data.btn_style,
btn_id: data.btn_id,
btn_position: data.btn_position,
btn_text: data.btn_text, btn_text: data.btn_text,
btn_logo: data.btn_logo, btn_logo: data.btn_logo,
modal_position: data.modal_position,
search_mode: data.search_mode,
placeholder: data.placeholder,
disclaimer: data.disclaimer,
recommend_questions: data.recommend_questions || [], recommend_questions: data.recommend_questions || [],
recommend_node_ids: data.recommend_node_ids || [], recommend_node_ids: data.recommend_node_ids || [],
}, },
@ -151,146 +181,469 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
</Link> </Link>
} }
> >
<FormItem label='网页挂件机器人'> <Stack spacing={3}>
<Controller <FormItem label='网页挂件机器人'>
control={control} <Controller
name='is_open' control={control}
render={({ field }) => ( name='is_open'
<RadioGroup render={({ field }) => (
row <RadioGroup
{...field} row
onChange={e => { {...field}
field.onChange(+e.target.value as 1 | 0); onChange={e => {
setIsEnabled((+e.target.value as 1 | 0) === 1); field.onChange(+e.target.value as 1 | 0);
setIsEdit(true); setIsEnabled((+e.target.value as 1 | 0) === 1);
}} setIsEdit(true);
}}
>
<FormControlLabel
value={1}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={0}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{isEnabled && (
<>
<FormItem
label='嵌入代码'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
> >
<FormControlLabel {url ? (
value={1} <ShowText
control={<Radio size='small' />} noEllipsis
label={<Box sx={{ width: 100 }}></Box>} text={[
/> `<!--// Head 标签引入样式 -->`,
<FormControlLabel `<link rel="stylesheet" href="${url}/widget-bot.css">`,
value={0} `<!--// Body 标签引入挂件 -->`,
control={<Radio size='small' />} `<script src="${url}/widget-bot.js"></script>`,
label={<Box sx={{ width: 100 }}></Box>} ]}
/> />
</RadioGroup> ) : (
)} <Stack
/> direction='row'
</FormItem> alignItems={'center'}
{isEnabled && ( gap={0.5}
<> sx={{
<FormItem label='配色方案'> color: 'warning.main',
<Controller fontSize: 14,
control={control} p: 1.5,
name='theme_mode' borderRadius: 1,
render={({ field }) => ( bgcolor: 'warning.light',
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}} }}
> >
<FormControlLabel <Icon type='icon-jinggao' />
value={'light'}
control={<Radio size='small' />} <Box component={'span'} sx={{ fontWeight: 500 }}>
label={<Box sx={{ width: 100 }}></Box>}
/> </Box>{' '}
<FormControlLabel
value={'dark'} </Stack>
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)} )}
/> </FormItem>
</FormItem> <FormItem
<FormItem label='侧边按钮文字'> label='挂件配置'
<Controller sx={{ alignItems: 'flex-start' }}
control={control} labelSx={{ mt: 1 }}
name='btn_text' >
render={({ field }) => ( <Box>
<TextField {!widgetConfigOpen && (
fullWidth <Button
{...field} size='small'
placeholder='输入侧边按钮文字' variant='outlined'
error={!!errors.btn_text} onClick={() => setWidgetConfigOpen(true)}
helperText={errors.btn_text?.message} endIcon={<ExpandMoreIcon />}
onChange={event => { >
setIsEdit(true);
field.onChange(event); </Button>
}} )}
/> <Collapse in={widgetConfigOpen}>
)} <Stack spacing={2.5}>
/> <FormItem
</FormItem> label='按钮样式'
<FormItem label='侧边按钮 Logo'> sx={{ alignItems: 'flex-start' }}
<Controller labelSx={{ mt: 1 }}
control={control} >
name='btn_logo' <Controller
render={({ field }) => ( control={control}
<UploadFile name='btn_style'
{...field} render={({ field }) => (
id='btn_logo' <RadioGroup
type='url' row
accept='image/*' {...field}
width={80} onChange={e => {
onChange={url => { const value = e.target.value;
field.onChange(url); field.onChange(value);
setIsEdit(true); if (value === 'btn_trigger') {
}} setValue('modal_position', 'fixed');
/> }
)} setIsEdit(true);
/> }}
</FormItem> >
<FormItem label='推荐问题'> <FormControlLabel
<FreeSoloAutocomplete value='hover_ball'
{...recommendQuestionsField} control={<Radio size='small' />}
placeholder='回车确认,填写下一个推荐问题' label={<Box sx={{ width: 100 }}></Box>}
/> />
</FormItem> <FormControlLabel
<FormItem label='推荐文档'> value='side_sticky'
<RecommendDocDragList control={<Radio size='small' />}
ids={recommend_node_ids} label={<Box sx={{ width: 100 }}></Box>}
onChange={(value: string[]) => { />
setIsEdit(true); <FormControlLabel
setValue('recommend_node_ids', value); value='btn_trigger'
}} control={<Radio size='small' />}
/> label={<Box sx={{ width: 100 }}></Box>}
</FormItem> />
<FormItem label='嵌入代码'> </RadioGroup>
{url ? ( )}
<ShowText />
noEllipsis </FormItem>
text={[ {isCustomButton ? (
`<!--// Head 标签引入样式 -->`, <FormItem
`<link rel="stylesheet" href="${url}/widget-bot.css">`, label='自定义按钮 ID'
'', required
`<!--// Body 标签引入挂件 -->`, sx={{ alignItems: 'flex-start' }}
`<script src="${url}/widget-bot.js"></script>`, labelSx={{ mt: 1 }}
]} >
/> <Controller
) : ( control={control}
<Stack name='btn_id'
direction='row' rules={{
alignItems={'center'} required: '自定义按钮 ID 不能为空',
gap={0.5} }}
sx={{ color: 'warning.main', fontSize: 14 }} render={({ field }) => (
> <TextField
<Icon type='icon-jinggao' /> {...field}
fullWidth
<Box component={'span'} sx={{ fontWeight: 500 }}> placeholder='嵌入网站中自定义按钮的 #id 点击触发,如: pandawiki-widget-bot-btn'
error={!!errors.btn_id}
</Box>{' '} helperText={errors.btn_id?.message}
onChange={event => {
</Stack> setIsEdit(true);
)} field.onChange(event);
</FormItem> }}
</> />
)} )}
/>
</FormItem>
) : (
<>
<FormItem
label='按钮位置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_position'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='top_left'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='top_right'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='bottom_left'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='bottom_right'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{btn_style !== 'hover_ball' && (
<FormItem
label='按钮文字'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_text'
render={({ field }) => (
<TextField
{...field}
fullWidth
placeholder='输入按钮文字'
error={!!errors.btn_text}
helperText={errors.btn_text?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
)}
<FormItem
label='按钮图标'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_logo'
render={({ field }) => (
<UploadFile
{...field}
id='btn_logo'
type='url'
accept='image/*'
width={80}
onChange={url => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</FormItem>
</>
)}
</Stack>
</Collapse>
</Box>
</FormItem>
<FormItem
label='弹框配置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Box>
{!modalConfigOpen && (
<Button
size='small'
variant='outlined'
onClick={() => setModalConfigOpen(true)}
endIcon={<ExpandMoreIcon />}
>
</Button>
)}
<Collapse in={modalConfigOpen}>
<Stack spacing={2.5}>
{/* <FormItem label='' sx={{ alignItems: 'flex-start' }} labelSx={{ mt: 1 }}>
<Controller
control={control}
name='theme_mode'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='light'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='dark'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='system'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='wiki'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}> WIKI </Box>}
/>
</RadioGroup>
)}
/>
</FormItem> */}
<FormItem
label='弹窗位置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='modal_position'
render={({ field }) => {
const isDisabled = btn_style === 'btn_trigger';
return (
<RadioGroup
row
{...field}
value={isDisabled ? 'fixed' : field.value}
onChange={e => {
if (!isDisabled) {
field.onChange(e.target.value);
setIsEdit(true);
}
}}
>
<FormControlLabel
value='follow'
control={
<Radio size='small' disabled={isDisabled} />
}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='fixed'
control={
<Radio size='small' disabled={isDisabled} />
}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
);
}}
/>
</FormItem>
<FormItem
label='搜索模式'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='search_mode'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='all'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='qa'
control={<Radio size='small' />}
label={
<Box sx={{ width: 100 }}></Box>
}
/>
<FormControlLabel
value='doc'
control={<Radio size='small' />}
label={
<Box sx={{ width: 100 }}></Box>
}
/>
</RadioGroup>
)}
/>
</FormItem>
<FormItem
label='搜索提示语'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='placeholder'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='输入搜索提示语'
error={!!errors.placeholder}
helperText={errors.placeholder?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
<FormItem
label='推荐问题'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<FreeSoloAutocomplete
{...recommendQuestionsField}
placeholder='回车确认,填写下一个推荐问题'
/>
</FormItem>
<FormItem
label='推荐文档'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<RecommendDocDragList
ids={recommend_node_ids}
onChange={(value: string[]) => {
setIsEdit(true);
setValue('recommend_node_ids', value);
}}
/>
</FormItem>
<FormItem
label='免责声明'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='disclaimer'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='输入免责声明'
error={!!errors.disclaimer}
helperText={errors.disclaimer?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
</Stack>
</Collapse>
</Box>
</FormItem>
</>
)}
</Stack>
</SettingCardItem> </SettingCardItem>
); );
}; };

View File

@ -1,7 +1,6 @@
import { DomainKnowledgeBaseDetail } from '@/request/types'; import { DomainKnowledgeBaseDetail } from '@/request/types';
import { import {
Box, Box,
Button,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
Link, Link,
@ -13,10 +12,11 @@ import {
import ShowText from '@/components/ShowText'; import ShowText from '@/components/ShowText';
import { getApiV1AppDetail, putApiV1App } from '@/request/App'; import { getApiV1AppDetail, putApiV1App } from '@/request/App';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { FormItem, SettingCardItem } from './Common'; import { FormItem, SettingCardItem } from './Common';
import { DomainAppDetailResp } from '@/request/types'; import { DomainAppDetailResp } from '@/request/types';
import { message } from '@ctzhian/ui'; import { message } from '@ctzhian/ui';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
const CardRobotApi = ({ const CardRobotApi = ({
@ -29,11 +29,6 @@ const CardRobotApi = ({
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const [detail, setDetail] = useState<DomainAppDetailResp | null>(null); const [detail, setDetail] = useState<DomainAppDetailResp | null>(null);
const { license } = useAppSelector(state => state.config); const { license } = useAppSelector(state => state.config);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const { const {
control, control,
handleSubmit, handleSubmit,
@ -114,10 +109,7 @@ const CardRobotApi = ({
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<FormItem <FormItem label='问答机器人 API' permission={BUSINESS_VERSION_PERMISSION}>
label='问答机器人 API'
tooltip={!isEnterprise ? '企业版可用' : undefined}
>
<FormControl> <FormControl>
<Controller <Controller
control={control} control={control}
@ -133,13 +125,11 @@ const CardRobotApi = ({
<Stack direction={'row'}> <Stack direction={'row'}>
<FormControlLabel <FormControlLabel
value={true} value={true}
disabled={!isEnterprise}
control={<Radio size='small' />} control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>} label={<Box sx={{ width: 100 }}></Box>}
/> />
<FormControlLabel <FormControlLabel
value={false} value={false}
disabled={!isEnterprise}
control={<Radio size='small' />} control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>} label={<Box sx={{ width: 100 }}></Box>}
/> />
@ -150,7 +140,7 @@ const CardRobotApi = ({
</FormControl> </FormControl>
</FormItem> </FormItem>
{isEnabled && ( {isEnabled && BUSINESS_VERSION_PERMISSION.includes(license.edition!) && (
<> <>
<FormItem label='API Token' required> <FormItem label='API Token' required>
<Controller <Controller

View File

@ -19,6 +19,8 @@ import {
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { FormItem, SettingCardItem } from './Common'; import { FormItem, SettingCardItem } from './Common';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
const CardRobotWecomService = ({ const CardRobotWecomService = ({
kb, kb,
@ -262,38 +264,40 @@ const CardRobotWecomService = ({
<Icon type='icon-jinggao' sx={{ fontSize: 18 }} /> <Icon type='icon-jinggao' sx={{ fontSize: 18 }} />
</Stack> </Stack>
<FormItem <VersionMask permission={PROFESSION_VERSION_PERMISSION}>
label={ <FormItem
<Box> label={
<Box>
<Box component={'span'} sx={{ fontWeight: 600 }}>
<Box component={'span'} sx={{ fontWeight: 600 }}>
</Box>
</Box> </Box>
}
</Box> >
} <FreeSoloAutocomplete
> placeholder='回车确认,填写下一个'
<FreeSoloAutocomplete {...containKeywordsField}
placeholder='回车确认,填写下一个' />
{...containKeywordsField} </FormItem>
/> <FormItem
</FormItem> label={
<FormItem <Box>
label={
<Box> <Box component={'span'} sx={{ fontWeight: 600 }}>
<Box component={'span'} sx={{ fontWeight: 600 }}> </Box>
</Box> </Box>
}
</Box> >
} <FreeSoloAutocomplete
> placeholder='回车确认,填写下一个'
<FreeSoloAutocomplete {...equalKeywordsField}
placeholder='回车确认,填写下一个' />
{...equalKeywordsField} </FormItem>
/> </VersionMask>
</FormItem>
</> </>
)} )}
</SettingCardItem> </SettingCardItem>

View File

@ -8,6 +8,7 @@ import {
} from '@/request/types'; } from '@/request/types';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { message } from '@ctzhian/ui'; import { message } from '@ctzhian/ui';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { import {
Autocomplete, Autocomplete,
Box, Box,
@ -17,7 +18,7 @@ import {
TextField, TextField,
styled, styled,
} from '@mui/material'; } from '@mui/material';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { FormItem, SettingCardItem } from './Common'; import { FormItem, SettingCardItem } from './Common';
@ -32,15 +33,9 @@ const WatermarkForm = ({
data?: DomainAppDetailResp; data?: DomainAppDetailResp;
refresh: () => void; refresh: () => void;
}) => { }) => {
const { license, kb_id } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const [watermarkIsEdit, setWatermarkIsEdit] = useState(false); const [watermarkIsEdit, setWatermarkIsEdit] = useState(false);
const { const { control, handleSubmit, setValue, watch } = useForm({
control,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({
defaultValues: { defaultValues: {
watermark_setting: data?.settings?.watermark_setting ?? null, watermark_setting: data?.settings?.watermark_setting ?? null,
watermark_content: data?.settings?.watermark_content ?? '', watermark_content: data?.settings?.watermark_content ?? '',
@ -48,9 +43,6 @@ const WatermarkForm = ({
}); });
const watermarkSetting = watch('watermark_setting'); const watermarkSetting = watch('watermark_setting');
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const handleSaveWatermark = handleSubmit(values => { const handleSaveWatermark = handleSubmit(values => {
if (!data?.id || values.watermark_setting === null) return; if (!data?.id || values.watermark_setting === null) return;
@ -82,8 +74,9 @@ const WatermarkForm = ({
title='水印' title='水印'
isEdit={watermarkIsEdit} isEdit={watermarkIsEdit}
onSubmit={handleSaveWatermark} onSubmit={handleSaveWatermark}
permission={BUSINESS_VERSION_PERMISSION}
> >
<FormItem label='水印开关' tooltip={!isEnterprise && '企业版可用'}> <FormItem label='水印开关'>
<Controller <Controller
control={control} control={control}
name='watermark_setting' name='watermark_setting'
@ -98,18 +91,18 @@ const WatermarkForm = ({
> >
<FormControlLabel <FormControlLabel
value={ConstsWatermarkSetting.WatermarkVisible} value={ConstsWatermarkSetting.WatermarkVisible}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={ConstsWatermarkSetting.WatermarkHidden} value={ConstsWatermarkSetting.WatermarkHidden}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={ConstsWatermarkSetting.WatermarkDisabled} value={ConstsWatermarkSetting.WatermarkDisabled}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
</RadioGroup> </RadioGroup>
@ -128,7 +121,6 @@ const WatermarkForm = ({
placeholder='请输入水印内容, 支持多行输入' placeholder='请输入水印内容, 支持多行输入'
multiline multiline
minRows={2} minRows={2}
disabled={!isEnterprise}
onChange={e => { onChange={e => {
setWatermarkIsEdit(true); setWatermarkIsEdit(true);
field.onChange(e.target.value); field.onChange(e.target.value);
@ -146,9 +138,6 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
const { license } = useAppSelector(state => state.config); const { license } = useAppSelector(state => state.config);
const [questionInputValue, setQuestionInputValue] = useState(''); const [questionInputValue, setQuestionInputValue] = useState('');
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const { control, handleSubmit, setValue } = useForm({ const { control, handleSubmit, setValue } = useForm({
defaultValues: { defaultValues: {
@ -169,17 +158,18 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
}); });
useEffect(() => { useEffect(() => {
if (!kb.id || !isEnterprise) return; if (!kb.id || !BUSINESS_VERSION_PERMISSION.includes(license.edition!))
return;
getApiProV1Block({ kb_id: kb.id! }).then(res => { getApiProV1Block({ kb_id: kb.id! }).then(res => {
setValue('block_words', res.words || []); setValue('block_words', res.words || []);
}); });
}, [kb, isEnterprise]); }, [kb, license.edition]);
return ( return (
<SettingCardItem title='内容合规' isEdit={isEdit} onSubmit={onSubmit}> <SettingCardItem title='内容合规' isEdit={isEdit} onSubmit={onSubmit}>
<FormItem <FormItem
vertical vertical
tooltip={!isEnterprise && '企业版可用'} permission={BUSINESS_VERSION_PERMISSION}
label='屏蔽 AI 问答中的关键字' label='屏蔽 AI 问答中的关键字'
> >
<Controller <Controller
@ -193,7 +183,6 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
inputValue={questionInputValue} inputValue={questionInputValue}
options={[]} options={[]}
fullWidth fullWidth
disabled={!isEnterprise}
onInputChange={(_, value) => { onInputChange={(_, value) => {
setQuestionInputValue(value); setQuestionInputValue(value);
}} }}
@ -234,23 +223,14 @@ const CopyForm = ({
data?: DomainAppDetailResp; data?: DomainAppDetailResp;
refresh: () => void; refresh: () => void;
}) => { }) => {
const { license, kb_id } = useAppSelector(state => state.config); const { kb_id } = useAppSelector(state => state.config);
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
const { const { control, handleSubmit, setValue } = useForm({
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm({
defaultValues: { defaultValues: {
copy_setting: data?.settings?.copy_setting ?? null, copy_setting: data?.settings?.copy_setting ?? null,
}, },
}); });
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const handleSaveWatermark = handleSubmit(values => { const handleSaveWatermark = handleSubmit(values => {
if (!data?.id || values.copy_setting === null) return; if (!data?.id || values.copy_setting === null) return;
putApiV1App( putApiV1App(
@ -280,7 +260,7 @@ const CopyForm = ({
isEdit={isEdit} isEdit={isEdit}
onSubmit={handleSaveWatermark} onSubmit={handleSaveWatermark}
> >
<FormItem label='限制复制' tooltip={!isEnterprise && '企业版可用'}> <FormItem label='限制复制' permission={BUSINESS_VERSION_PERMISSION}>
<Controller <Controller
control={control} control={control}
name='copy_setting' name='copy_setting'
@ -295,18 +275,18 @@ const CopyForm = ({
> >
<FormControlLabel <FormControlLabel
value={ConstsCopySetting.CopySettingNone} value={ConstsCopySetting.CopySettingNone}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={ConstsCopySetting.CopySettingAppend} value={ConstsCopySetting.CopySettingAppend}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
<FormControlLabel <FormControlLabel
value={ConstsCopySetting.CopySettingDisabled} value={ConstsCopySetting.CopySettingDisabled}
control={<Radio size='small' disabled={!isEnterprise} />} control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>} label={<StyledRadioLabel></StyledRadioLabel>}
/> />
</RadioGroup> </RadioGroup>

View File

@ -26,7 +26,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
defaultValues: { defaultValues: {
desc: '', desc: '',
keyword: '', keyword: '',
auto_sitemap: false,
}, },
}); });
@ -44,7 +43,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
useEffect(() => { useEffect(() => {
setValue('desc', data.settings?.desc || ''); setValue('desc', data.settings?.desc || '');
setValue('keyword', data.settings?.keyword || ''); setValue('keyword', data.settings?.keyword || '');
setValue('auto_sitemap', data.settings?.auto_sitemap ?? false);
}, [data]); }, [data]);
return ( return (
@ -88,25 +86,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
)} )}
/> />
</FormItem> </FormItem>
<FormItem label='自动生成 Sitemap'>
<Controller
control={control}
name='auto_sitemap'
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
size='small'
sx={{ p: 0, m: 0 }}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
</SettingCardItem> </SettingCardItem>
); );
}; };

View File

@ -1,7 +1,9 @@
import Card from '@/components/Card'; import Card from '@/components/Card';
import { ConstsLicenseEdition } from '@/request/types';
import InfoIcon from '@mui/icons-material/Info'; import InfoIcon from '@mui/icons-material/Info';
import { Button, Stack, styled, SxProps, Tooltip } from '@mui/material'; import { Button, Stack, styled, SxProps, Tooltip } from '@mui/material';
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import VersionMask from '@/components/VersionMask';
const StyledForm = styled('form')<{ gap?: number | string }>( const StyledForm = styled('form')<{ gap?: number | string }>(
({ theme, gap = 2 }) => ({ ({ theme, gap = 2 }) => ({
@ -40,6 +42,7 @@ const StyledFormLabel = styled('span')<{ required?: boolean }>(
export const StyledFormItem = styled('div')<{ vertical?: boolean }>( export const StyledFormItem = styled('div')<{ vertical?: boolean }>(
({ theme, vertical }) => ({ ({ theme, vertical }) => ({
position: 'relative',
display: 'flex', display: 'flex',
alignItems: vertical ? 'flex-start' : 'center', alignItems: vertical ? 'flex-start' : 'center',
flexDirection: vertical ? 'column' : 'row', flexDirection: vertical ? 'column' : 'row',
@ -82,6 +85,7 @@ export const FormItem = ({
extra, extra,
sx, sx,
labelSx, labelSx,
permission,
}: { }: {
label?: string | React.ReactNode; label?: string | React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
@ -92,31 +96,37 @@ export const FormItem = ({
extra?: React.ReactNode; extra?: React.ReactNode;
sx?: SxProps; sx?: SxProps;
labelSx?: SxProps; labelSx?: SxProps;
permission?: number[];
}) => { }) => {
const { vertical: verticalContext, labelWidth: labelWidthContext } = const { vertical: verticalContext, labelWidth: labelWidthContext } =
useContext(FormContext); useContext(FormContext);
return (
<StyledFormItem vertical={vertical || verticalContext} sx={sx}>
<StyledFormLabelWrapper
vertical={vertical || verticalContext}
labelWidth={labelWidth || labelWidthContext}
sx={labelSx}
>
<Stack direction='row' alignItems='center' flex={1}>
<StyledFormLabel required={required}>{label}</StyledFormLabel>
{tooltip && typeof tooltip === 'string' ? (
<Tooltip title={tooltip} placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }} />
</Tooltip>
) : (
tooltip
)}
</Stack>
{extra} return (
</StyledFormLabelWrapper> <VersionMask permission={permission}>
{children} <StyledFormItem vertical={vertical || verticalContext} sx={sx}>
</StyledFormItem> <StyledFormLabelWrapper
vertical={vertical || verticalContext}
labelWidth={labelWidth || labelWidthContext}
sx={labelSx}
>
<Stack direction='row' alignItems='center' flex={1}>
<StyledFormLabel required={required}>{label}</StyledFormLabel>
{tooltip && typeof tooltip === 'string' ? (
<Tooltip title={tooltip} placement='top' arrow>
<InfoIcon
sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }}
/>
</Tooltip>
) : (
tooltip
)}
</Stack>
{extra}
</StyledFormLabelWrapper>
{children}
</StyledFormItem>
</VersionMask>
); );
}; };
@ -142,6 +152,7 @@ export const SettingCard = ({
}; };
const StyledSettingCardItem = styled('div')(({ theme }) => ({ const StyledSettingCardItem = styled('div')(({ theme }) => ({
position: 'relative',
'&:not(:last-child)': { '&:not(:last-child)': {
borderBottom: `1px solid ${theme.palette.divider}`, borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
@ -204,6 +215,12 @@ export const SettingCardItem = ({
extra, extra,
more, more,
sx, sx,
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
title?: React.ReactNode; title?: React.ReactNode;
@ -212,6 +229,7 @@ export const SettingCardItem = ({
extra?: React.ReactNode; extra?: React.ReactNode;
more?: SettingCardItemMore; more?: SettingCardItemMore;
sx?: SxProps; sx?: SxProps;
permission?: number[];
}) => { }) => {
const renderMore = (more: SettingCardItemMore) => { const renderMore = (more: SettingCardItemMore) => {
if (more && typeof more === 'object' && 'type' in more) { if (more && typeof more === 'object' && 'type' in more) {
@ -237,20 +255,23 @@ export const SettingCardItem = ({
return more; return more;
} }
}; };
return ( return (
<StyledSettingCardItem sx={sx}> <VersionMask permission={permission}>
<StyledSettingCardItemTitleWrapper> <StyledSettingCardItem sx={sx}>
<StyledSettingCardItemTitle> <StyledSettingCardItemTitleWrapper>
{title} {renderMore(more)} <StyledSettingCardItemTitle>
</StyledSettingCardItemTitle> {title} {renderMore(more)}
{isEdit && ( </StyledSettingCardItemTitle>
<Button variant='contained' size='small' onClick={onSubmit}> {isEdit && (
<Button variant='contained' size='small' onClick={onSubmit}>
</Button>
)} </Button>
{extra} )}
</StyledSettingCardItemTitleWrapper> {extra}
<StyledSettingCardItemContent>{children}</StyledSettingCardItemContent> </StyledSettingCardItemTitleWrapper>
</StyledSettingCardItem> <StyledSettingCardItemContent>{children}</StyledSettingCardItemContent>
</StyledSettingCardItem>
</VersionMask>
); );
}; };

View File

@ -1,9 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { SettingCardItem } from '../Common'; import { SettingCardItem } from '../Common';
import { Tooltip } from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import { Modal, message } from '@ctzhian/ui'; import { Modal, message } from '@ctzhian/ui';
import { Stack, Button } from '@mui/material';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { postApiProV1AuthGroupSync } from '@/request/pro/AuthOrg'; import { postApiProV1AuthGroupSync } from '@/request/pro/AuthOrg';
import { import {
@ -19,6 +16,7 @@ import {
deleteApiProV1AuthGroupDelete, deleteApiProV1AuthGroupDelete,
} from '@/request/pro/AuthGroup'; } from '@/request/pro/AuthGroup';
import GroupTree from './GroupTree'; import GroupTree from './GroupTree';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
interface UserGroupProps { interface UserGroupProps {
enabled: string; enabled: string;
@ -45,10 +43,6 @@ const UserGroup = ({
GithubComChaitinPandaWikiProApiAuthV1AuthGroupTreeItem[] GithubComChaitinPandaWikiProApiAuthV1AuthGroupTreeItem[]
>([]); >([]);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const onDeleteUserGroup = (id: number) => { const onDeleteUserGroup = (id: number) => {
Modal.confirm({ Modal.confirm({
title: '删除用户组', title: '删除用户组',
@ -74,10 +68,15 @@ const UserGroup = ({
}); });
}; };
useEffect(() => { useEffect(() => {
if (!kb_id || enabled !== '2' || !isEnterprise) return; if (
!kb_id ||
enabled !== '2' ||
!BUSINESS_VERSION_PERMISSION.includes(license.edition!)
)
return;
getUserGroup(); getUserGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [kb_id, enabled, isEnterprise]); }, [kb_id, enabled, license.edition!]);
const handleMove = async ({ const handleMove = async ({
id, id,
@ -123,32 +122,7 @@ const UserGroup = ({
}; };
return ( return (
<SettingCardItem <SettingCardItem title='用户组' permission={BUSINESS_VERSION_PERMISSION}>
title='用户组'
more={
!isEnterprise && (
<Tooltip title='企业版可用' placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }} />
</Tooltip>
)
}
// extra={
// isEnterprise &&
// [
// ConstsSourceType.SourceTypeWeCom,
// ConstsSourceType.SourceTypeDingTalk,
// ].includes(sourceType as ConstsSourceType) && (
// <Button
// color='primary'
// size='small'
// onClick={handleSync}
// loading={syncLoading}
// >
// 同步组织架构和成员
// </Button>
// )
// }
>
<Box <Box
sx={{ sx={{
border: '1px dashed', border: '1px dashed',

View File

@ -9,6 +9,11 @@ import QAReferer from './QAReferer';
import RTVisitor from './RTVisitor'; import RTVisitor from './RTVisitor';
import TypeCount from './TypeCount'; import TypeCount from './TypeCount';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { VersionCanUse } from '@/components/VersionMask';
import {
BUSINESS_VERSION_PERMISSION,
PROFESSION_VERSION_PERMISSION,
} from '@/constant/version';
export const TimeList = [ export const TimeList = [
{ label: '近 24 小时', value: 1 }, { label: '近 24 小时', value: 1 },
@ -25,13 +30,40 @@ const Statistic = () => {
const isWideScreen = useMediaQuery('(min-width:1190px)'); const isWideScreen = useMediaQuery('(min-width:1190px)');
const timeList = useMemo(() => { const timeList = useMemo(() => {
const isPro = license.edition === 1 || license.edition === 2; const isPro = PROFESSION_VERSION_PERMISSION.includes(license.edition!);
const isEnterprise = license.edition === 2; const isBusiness = BUSINESS_VERSION_PERMISSION.includes(license.edition!);
return [ return [
{ label: '近 24 小时', value: 1, disabled: false }, { label: '近 24 小时', value: 1, disabled: false },
{ label: '近 7 天', value: 7, disabled: !isPro }, {
{ label: '近 30 天', value: 30, disabled: !isEnterprise }, label: (
{ label: '近 90 天', value: 90, disabled: !isEnterprise }, <Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 7 </span>
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</Stack>
),
value: 7,
disabled: !isPro,
},
{
label: (
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 30 </span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: 30,
disabled: !isBusiness,
},
{
label: (
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 90 </span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: 90,
disabled: !isBusiness,
},
]; ];
}, [license]); }, [license]);

View File

@ -26,6 +26,8 @@ import {
GetApiV1NodeListParams, GetApiV1NodeListParams,
GetApiV1NodeRecommendNodesParams, GetApiV1NodeRecommendNodesParams,
V1NodeDetailResp, V1NodeDetailResp,
V1NodeRestudyReq,
V1NodeRestudyResp,
} from "./types"; } from "./types";
/** /**
@ -263,6 +265,38 @@ export const getApiV1NodeRecommendNodes = (
...params, ...params,
}); });
/**
* @description
*
* @tags Node
* @name PostApiV1NodeRestudy
* @summary
* @request POST:/api/v1/node/restudy
* @secure
* @response `200` `(DomainResponse & {
data?: V1NodeRestudyResp,
})` OK
*/
export const postApiV1NodeRestudy = (
param: V1NodeRestudyReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: V1NodeRestudyResp;
}
>({
path: `/api/v1/node/restudy`,
method: "POST",
body: param,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});
/** /**
* @description Summary Node * @description Summary Node
* *

View File

@ -1,46 +0,0 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import { DomainResponse, V1NodeRestudyReq, V1NodeRestudyResp } from "./types";
/**
* @description
*
* @tags NodeRestudy
* @name PostApiV1NodeRestudy
* @summary
* @request POST:/api/v1/node/restudy
* @secure
* @response `200` `(DomainResponse & {
data?: V1NodeRestudyResp,
})` OK
*/
export const postApiV1NodeRestudy = (
param: V1NodeRestudyReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: V1NodeRestudyResp;
}
>({
path: `/api/v1/node/restudy`,
method: "POST",
body: param,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -10,7 +10,6 @@ export * from './Message'
export * from './Model' export * from './Model'
export * from './Node' export * from './Node'
export * from './NodePermission' export * from './NodePermission'
export * from './NodeRestudy'
export * from './Stat' export * from './Stat'
export * from './User' export * from './User'
export * from './types' export * from './types'

View File

@ -24,6 +24,7 @@ import {
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp, GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq, GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp, GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq, GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp, GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq, GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
@ -206,6 +207,32 @@ export const postShareProV1AuthLdap = (
...params, ...params,
}); });
/**
* @description
*
* @tags ShareAuth
* @name PostShareProV1AuthLogout
* @summary
* @request POST:/share/pro/v1/auth/logout
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
})` OK
*/
export const postShareProV1AuthLogout = (params: RequestParams = {}) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp;
}
>({
path: `/share/pro/v1/auth/logout`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
});
/** /**
* @description OAuth登录 * @description OAuth登录
* *

View File

@ -52,10 +52,12 @@ export enum ConstsSourceType {
export enum ConstsLicenseEdition { export enum ConstsLicenseEdition {
/** 开源版 */ /** 开源版 */
LicenseEditionFree = 0, LicenseEditionFree = 0,
/** 联创版 */ /** 专业版 */
LicenseEditionContributor = 1, LicenseEditionProfession = 1,
/** 企业版 */ /** 企业版 */
LicenseEditionEnterprise = 2, LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
} }
export enum ConstsContributeType { export enum ConstsContributeType {
@ -455,6 +457,11 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
any any
>; >;
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
string,
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq { export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
kb_id?: string; kb_id?: string;
redirect_url?: string; redirect_url?: string;
@ -465,6 +472,7 @@ export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp {
} }
export interface GithubComChaitinPandaWikiProApiShareV1AuthWecomReq { export interface GithubComChaitinPandaWikiProApiShareV1AuthWecomReq {
is_app?: boolean;
kb_id?: string; kb_id?: string;
redirect_url?: string; redirect_url?: string;
} }
@ -668,8 +676,6 @@ export interface GetApiProV1TokenListParams {
} }
export interface PostApiV1LicensePayload { export interface PostApiV1LicensePayload {
/** license edition */
license_edition: "contributor" | "enterprise";
/** license type */ /** license type */
license_type: "file" | "code"; license_type: "file" | "code";
/** /**

View File

@ -171,14 +171,21 @@ export enum ConstsNodeAccessPerm {
NodeAccessPermClosed = "closed", NodeAccessPermClosed = "closed",
} }
export enum ConstsModelSettingMode {
ModelSettingModeManual = "manual",
ModelSettingModeAuto = "auto",
}
/** @format int32 */ /** @format int32 */
export enum ConstsLicenseEdition { export enum ConstsLicenseEdition {
/** 开源版 */ /** 开源版 */
LicenseEditionFree = 0, LicenseEditionFree = 0,
/** 联创版 */ /** 专业版 */
LicenseEditionContributor = 1, LicenseEditionProfession = 1,
/** 企业版 */ /** 企业版 */
LicenseEditionEnterprise = 2, LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
} }
export enum ConstsHomePageSetting { export enum ConstsHomePageSetting {
@ -927,8 +934,10 @@ export interface DomainModelModeSetting {
auto_mode_api_key?: string; auto_mode_api_key?: string;
/** 自定义对话模型名称 */ /** 自定义对话模型名称 */
chat_model?: string; chat_model?: string;
/** 手动模式下嵌入模型是否更新 */
is_manual_embedding_updated?: boolean;
/** 模式: manual 或 auto */ /** 模式: manual 或 auto */
mode?: string; mode?: ConstsModelSettingMode;
} }
export interface DomainMoveNodeReq { export interface DomainMoveNodeReq {
@ -1180,6 +1189,17 @@ export interface DomainShareConversationMessage {
role?: SchemaRoleType; role?: SchemaRoleType;
} }
export interface DomainShareNodeListItemResp {
emoji?: string;
id?: string;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
position?: number;
type?: DomainNodeType;
updated_at?: string;
}
export interface DomainSimpleAuth { export interface DomainSimpleAuth {
enabled?: boolean; enabled?: boolean;
password?: string; password?: string;
@ -1357,11 +1377,18 @@ export interface DomainWecomAIBotSettings {
} }
export interface DomainWidgetBotSettings { export interface DomainWidgetBotSettings {
btn_id?: string;
btn_logo?: string; btn_logo?: string;
btn_position?: string;
btn_style?: string;
btn_text?: string; btn_text?: string;
disclaimer?: string;
is_open?: boolean; is_open?: boolean;
modal_position?: string;
placeholder?: string;
recommend_node_ids?: string[]; recommend_node_ids?: string[];
recommend_questions?: string[]; recommend_questions?: string[];
search_mode?: string;
theme_mode?: string; theme_mode?: string;
} }
@ -1645,8 +1672,9 @@ export interface V1NodePermissionResp {
} }
export interface V1NodeRestudyReq { export interface V1NodeRestudyReq {
kb_id?: string; kb_id: string;
node_ids?: string[]; /** @minItems 1 */
node_ids: string[];
} }
export type V1NodeRestudyResp = Record<string, any>; export type V1NodeRestudyResp = Record<string, any>;
@ -1666,6 +1694,7 @@ export interface V1ShareNodeDetailResp {
editor_id?: string; editor_id?: string;
id?: string; id?: string;
kb_id?: string; kb_id?: string;
list?: DomainShareNodeListItemResp[];
meta?: DomainNodeMeta; meta?: DomainNodeMeta;
name?: string; name?: string;
parent_id?: string; parent_id?: string;

View File

@ -1,4 +1,4 @@
FROM node:20-alpine FROM node:22-alpine
ENV NODE_ENV=production ENV NODE_ENV=production

View File

@ -72,7 +72,7 @@ export default isDevelopment
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill. // This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- // Note: Check that the configured route will not match with your Next.js proxy, otherwise reporting of client-
// side errors will fail. // side errors will fail.
tunnelRoute: '/monitoring', tunnelRoute: '/monitoring',

View File

@ -3,7 +3,7 @@
"version": "2.9.0", "version": "2.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 3010", "dev": "next dev -p 3010",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -16,7 +16,7 @@
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@mui/material-nextjs": "^7.1.0", "@mui/material-nextjs": "^7.3.5",
"@sentry/nextjs": "^10.8.0", "@sentry/nextjs": "^10.8.0",
"@types/markdown-it": "13.0.1", "@types/markdown-it": "13.0.1",
"@vscode/markdown-it-katex": "^1.1.2", "@vscode/markdown-it-katex": "^1.1.2",
@ -25,12 +25,13 @@
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html-react-parser": "^5.2.5", "html-react-parser": "^5.2.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"import-in-the-middle": "^1.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"katex": "^0.16.22", "katex": "^0.16.22",
"markdown-it": "13.0.1", "markdown-it": "13.0.1",
"markdown-it-highlightjs": "^4.2.0", "markdown-it-highlightjs": "^4.2.0",
"mermaid": "^11.9.0", "mermaid": "^11.9.0",
"next": "15.4.6", "next": "^16.0.0",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
@ -41,17 +42,23 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"require-in-the-middle": "^7.5.2",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@ctzhian/cx-swagger-api": "^1.0.0", "@ctzhian/cx-swagger-api": "^1.0.0",
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@next/eslint-plugin-next": "^15.4.5", "@next/eslint-plugin-next": "^16.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/rangy": "^1.3.0", "@types/rangy": "^1.3.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"eslint-config-next": "15.3.2", "eslint-config-next": "16.0.0",
"eslint-config-prettier": "^9.1.2" "eslint-config-prettier": "^9.1.2"
}, },
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
"pnpm": {
"overrides": {
"require-in-the-middle": "^7.5.2"
}
}
} }

View File

@ -1,35 +1,39 @@
/* 挂件按钮样式 - 基于MUI主题 */ /* 挂件按钮基础样式 */
.widget-bot-button { .widget-bot-button {
position: fixed; position: fixed;
right: 0;
bottom: 190px;
z-index: 9999; z-index: 9999;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border-radius: 18px 0 0 18px;
color: #FFFFFF; color: #FFFFFF;
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
padding: 11px;
min-height: 120px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
border: none; border: none;
opacity: 0;
transform: translateY(20px);
/* 优化拖拽性能 */
will-change: transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
} }
.widget-bot-button:hover { .widget-bot-button:hover:not(.dragging) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2); }
.widget-bot-hover-ball:hover:not(.dragging) {
transform: scale(1.1) !important;
} }
.widget-bot-button.dragging { .widget-bot-button.dragging {
cursor: grabbing; cursor: grabbing;
transform: rotate(2deg); transition: none !important;
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25); /* 拖拽时禁用过渡,提升性能 */
/* transform 由 JS 控制,包含 rotate 和 translate */
} }
.widget-bot-button-content { .widget-bot-button-content {
@ -39,14 +43,13 @@
color: inherit; color: inherit;
} }
.widget-bot-logo { /* 图标样式 */
width: 20px; .widget-bot-icon {
height: 20px;
margin-bottom: 8px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
} }
/* 文字样式 */
.widget-bot-text { .widget-bot-text {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
@ -60,6 +63,47 @@
margin: 1px 0; margin: 1px 0;
} }
/* 侧边吸附按钮样式 */
.widget-bot-side-sticky {
width: 48px;
padding: 6px 6px 12px 6px;
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border-radius: 24px;
border: 1px solid #ECEEF1;
min-height: auto;
}
.widget-bot-side-sticky .widget-bot-icon {
width: 36px;
height: 36px;
margin-bottom: 4px;
}
.widget-bot-side-sticky .widget-bot-text {
font-size: 12px;
color: #646a73;
line-height: 16px;
}
/* 悬浮球按钮样式 */
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
padding: 0;
min-height: auto;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 48px;
height: 48px;
margin-bottom: 0;
}
/* 模态框样式 - 基于MUI主题 */ /* 模态框样式 - 基于MUI主题 */
.widget-bot-modal { .widget-bot-modal {
position: fixed; position: fixed;
@ -75,6 +119,11 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.widget-bot-modal-fixed {
align-items: center;
justify-content: center;
}
.widget-bot-modal-content { .widget-bot-modal-content {
position: absolute; position: absolute;
width: 600px; width: 600px;
@ -88,6 +137,14 @@
animation: slideInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1); animation: slideInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.widget-bot-modal-content-fixed {
position: relative;
width: 800px;
height: auto;
max-height: 90vh;
margin: auto;
}
@keyframes slideInUp { @keyframes slideInUp {
from { from {
opacity: 0; opacity: 0;
@ -100,34 +157,30 @@
} }
} }
/* 关闭按钮样式 - 基于MUI IconButton */ /* 关闭按钮样式 - 透明框 */
.widget-bot-close-btn { .widget-bot-close-btn {
position: absolute; position: absolute;
top: 12px; top: 22.5px;
right: 12px; right: 16px;
background: none; background: transparent;
width: 36px; width: 36.26px;
height: 36px; height: 25px;
border: none; border: none;
border-radius: 50%; border-radius: 0;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 18px; font-size: 0;
opacity: 0.5;
z-index: 10001;
transition: all 0.1s ease-in-out;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
.widget-bot-close-btn:hover {
opacity: 1; opacity: 1;
} z-index: 10001;
transition: none;
.widget-bot-close-btn:active { font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
transform: scale(0.95); padding: 0;
margin: 0;
pointer-events: none;
/* 允许鼠标穿透到下方 */
} }
/* iframe样式 */ /* iframe样式 */
@ -140,6 +193,11 @@
background: #F8F9FA; background: #F8F9FA;
} }
.widget-bot-modal-content-fixed .widget-bot-iframe {
min-height: 600px;
height: auto;
}
/* 防止页面滚动 */ /* 防止页面滚动 */
body.widget-bot-modal-open { body.widget-bot-modal-open {
overflow: hidden; overflow: hidden;
@ -147,19 +205,34 @@ body.widget-bot-modal-open {
/* 暗色主题支持 - 基于data-theme属性 */ /* 暗色主题支持 - 基于data-theme属性 */
.widget-bot-button[data-theme="dark"] { .widget-bot-button[data-theme="dark"] {
background: #6E73FE;
box-shadow: 0 2px 4px rgba(110, 115, 254, 0.15); box-shadow: 0 2px 4px rgba(110, 115, 254, 0.15);
} }
.widget-bot-button[data-theme="dark"]:hover { .widget-bot-side-sticky[data-theme="dark"] {
background: #6E73FE;
}
.widget-bot-side-sticky[data-theme="dark"]:hover {
background: #5d68fd; background: #5d68fd;
box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2); box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2);
} }
.widget-bot-button[data-theme="dark"].dragging { .widget-bot-side-sticky[data-theme="dark"].dragging {
box-shadow: 0 6px 12px rgba(110, 115, 254, 0.25); box-shadow: 0 6px 12px rgba(110, 115, 254, 0.25);
} }
.widget-bot-hover-ball[data-theme="dark"] {
background: #6E73FE;
}
.widget-bot-hover-ball[data-theme="dark"]:hover {
transform: scale(1.1) !important;
}
.widget-bot-hover-ball[data-theme="dark"].dragging {
box-shadow: 0 8px 20px rgba(110, 115, 254, 0.3);
}
.widget-bot-modal[data-theme="dark"] { .widget-bot-modal[data-theme="dark"] {
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
} }
@ -169,61 +242,63 @@ body.widget-bot-modal-open {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
} }
/* 移动端适配 */ /* 移动端适配 - 统一处理 */
@media (max-width: 768px) { @media (max-width: 768px) {
.widget-bot-button { .widget-bot-side-sticky {
bottom: 16px; width: 48px;
padding: 8px; padding: 6px 6px 12px 6px;
border-radius: 10px 0 0 10px; border-radius: 24px;
}
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
padding: 0;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 48px;
height: 48px;
margin-bottom: 0;
} }
.widget-bot-text { .widget-bot-text {
font-size: 12px; font-size: 12px;
} }
.widget-bot-logo { .widget-bot-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-bottom: 6px; margin-bottom: 6px;
} }
/* 移动端弹框统一居中显示宽度100%-32px高度90vh */
.widget-bot-modal-content { .widget-bot-modal-content {
width: calc(100% - 60.5px); position: relative !important;
height: 90%; width: calc(100% - 32px) !important;
max-width: none; height: 90vh !important;
max-height: none; max-width: none !important;
max-height: 90vh !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
margin: auto !important;
}
.widget-bot-modal-content-fixed {
width: calc(100% - 32px) !important;
height: 90vh !important;
max-height: 90vh !important;
} }
.widget-bot-close-btn { .widget-bot-close-btn {
top: 8px; top: 22.5px;
right: 8px; right: 16px;
width: 32px; width: 36.26px;
height: 32px; height: 25px;
font-size: 16px; font-size: 0;
}
}
/* 小屏幕适配 */
@media (max-width: 480px) {
.widget-bot-button {
bottom: 12px;
padding: 6px;
}
.widget-bot-text {
font-size: 11px;
}
.widget-bot-modal-content {
width: calc(100% - 55.5px);
height: 90%;
border-radius: 6px;
}
.widget-bot-close-btn {
width: 28px;
height: 28px;
font-size: 14px;
} }
} }
@ -274,19 +349,32 @@ body.widget-bot-modal-open {
} }
/* 浅色主题样式 - 显式定义 */ /* 浅色主题样式 - 显式定义 */
.widget-bot-button[data-theme="light"] { .widget-bot-side-sticky[data-theme="light"] {
background: #3248F2; background: #FFFFFF;
color: #FFFFFF; box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17); border: 1px solid #ECEEF1;
} }
.widget-bot-button[data-theme="light"]:hover { .widget-bot-side-sticky[data-theme="light"]:hover {
background: #2a3cdb; background: #FFFFFF;
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
} }
.widget-bot-button[data-theme="light"].dragging { .widget-bot-side-sticky[data-theme="light"].dragging {
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25); background: #FFFFFF;
}
.widget-bot-hover-ball[data-theme="light"] {
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
}
.widget-bot-hover-ball[data-theme="light"]:hover {
transform: scale(1.1) !important;
}
.widget-bot-hover-ball[data-theme="light"].dragging {
box-shadow: 0 8px 20px rgba(50, 72, 242, 0.3);
} }
.widget-bot-modal[data-theme="light"] { .widget-bot-modal[data-theme="light"] {

View File

@ -1,6 +1,10 @@
(function () { (function () {
'use strict'; 'use strict';
const defaultModalPosition = 'follow';
const defaultBtnPosition = 'bottom_left';
const defaultBtnStyle = 'side_sticky';
// 获取当前脚本的域名 // 获取当前脚本的域名
const currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]'); const currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]');
const widgetDomain = currentScript ? new URL(currentScript.src).origin : window.location.origin; const widgetDomain = currentScript ? new URL(currentScript.src).origin : window.location.origin;
@ -11,6 +15,13 @@
let isDragging = false; let isDragging = false;
let dragOffset = { x: 0, y: 0 }; let dragOffset = { x: 0, y: 0 };
let currentTheme = 'light'; // 默认浅色主题 let currentTheme = 'light'; // 默认浅色主题
let customTriggerElement = null; // 自定义触发元素
let customTriggerHandler = null; // 自定义触发元素的事件处理函数
let dragAnimationFrame = null; // 拖拽动画帧ID
let buttonSize = { width: 0, height: 0 }; // 缓存按钮尺寸
let initialPosition = { left: 0, top: 0 }; // 拖拽开始时的初始位置
let hasDragged = false; // 标记是否发生了拖拽
let dragStartPos = { x: 0, y: 0 }; // 拖拽开始时的鼠标位置
// 应用主题 // 应用主题
function applyTheme(theme_mode) { function applyTheme(theme_mode) {
@ -60,13 +71,22 @@
applyTheme(widgetInfo.theme_mode); applyTheme(widgetInfo.theme_mode);
} }
createWidget(); // 根据 btn_style 创建不同的挂件
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'btn_trigger') {
createCustomTrigger();
} else {
createWidget();
}
} catch (error) { } catch (error) {
console.error('获取挂件信息失败:', error); console.error('获取挂件信息失败:', error);
// 使用默认值 // 使用默认值
widgetInfo = { widgetInfo = {
btn_text: '在线客服', btn_text: '在线客服',
btn_logo: '', btn_logo: `''`,
btn_style: defaultBtnStyle,
btn_position: defaultBtnPosition,
modal_position: defaultModalPosition,
theme_mode: 'light' theme_mode: 'light'
}; };
applyTheme(widgetInfo.theme_mode); applyTheme(widgetInfo.theme_mode);
@ -78,53 +98,92 @@
} }
} }
// 创建垂直文字 // 创建两行文字(每行两个字)
function createVerticalText(text) { function createTwoLineText(text) {
return text.split('').map((char, index) => const chars = text.split('').filter(it => !!it.trim());
`<span>${char}</span>` const lines = [];
).join(''); for (let i = 0; i < chars.length; i += 2) {
lines.push(chars.slice(i, i + 2).join(''));
}
return lines.map(line => `<span>${line}</span>`).join('');
} }
// 创建挂件按钮 // 应用按钮位置
function createWidget() { function applyButtonPosition(button, position) {
// 如果已存在,先删除 const pos = position || defaultBtnPosition;
if (widgetButton) { button.style.top = 'auto';
widgetButton.remove(); button.style.right = 'auto';
} button.style.bottom = 'auto';
button.style.left = 'auto';
// 创建按钮容器 // 两种模式使用相同的默认位置距离边缘16px垂直方向190px
switch (pos) {
case 'top_left':
button.style.top = '190px';
button.style.left = '16px';
break;
case 'top_right':
button.style.top = '190px';
button.style.right = '16px';
break;
case 'bottom_left':
button.style.bottom = '190px';
button.style.left = '16px';
break;
case 'bottom_right':
default:
button.style.bottom = '190px';
button.style.right = '16px';
break;
}
}
// 创建侧边吸附按钮
function createSideStickyButton() {
widgetButton = document.createElement('div'); widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button'; widgetButton.className = 'widget-bot-button widget-bot-side-sticky';
widgetButton.setAttribute('role', 'button'); widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0'); widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text}窗口`); widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme); widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div'); const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content'; buttonContent.className = 'widget-bot-button-content';
// 添加logo如果有 // 侧边吸附显示图标和文字btn_logo 以及 btn_text
if (widgetInfo.btn_logo) { const icon = document.createElement('img');
const logo = document.createElement('img'); const defaultIconSrc = widgetDomain + '/favicon.png';
logo.src = widgetDomain + widgetInfo.btn_logo; icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
logo.alt = 'logo'; icon.alt = 'icon';
logo.className = 'widget-bot-logo'; icon.className = 'widget-bot-icon';
logo.onerror = () => { icon.onerror = () => {
logo.style.display = 'none'; // 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
}; if (icon.src !== defaultIconSrc) {
buttonContent.appendChild(logo); icon.src = defaultIconSrc;
} } else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
// 添加文字 // 添加文字
const textDiv = document.createElement('div'); const textDiv = document.createElement('div');
textDiv.className = 'widget-bot-text'; textDiv.className = 'widget-bot-text';
textDiv.innerHTML = createVerticalText(widgetInfo.btn_text || '在线客服'); textDiv.innerHTML = createTwoLineText(widgetInfo.btn_text || '在线客服');
buttonContent.appendChild(textDiv); buttonContent.appendChild(textDiv);
widgetButton.appendChild(buttonContent); widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
const position = widgetInfo.btn_position || defaultBtnPosition;
applyButtonPosition(widgetButton, position);
// 设置 border-radius 为 24px统一圆角
widgetButton.style.borderRadius = '24px';
// 添加事件监听器 // 添加事件监听器
widgetButton.addEventListener('click', showModal); widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag); widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown); widgetButton.addEventListener('keydown', handleKeyDown);
@ -134,6 +193,69 @@
widgetButton.addEventListener('touchend', handleTouchEnd); widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton); document.body.appendChild(widgetButton);
}
// 创建悬浮球按钮
function createHoverBallButton() {
widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button widget-bot-hover-ball';
widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content';
// 悬浮球只显示图标btn_logo
const icon = document.createElement('img');
const defaultIconSrc = widgetDomain + '/favicon.png';
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
icon.alt = 'icon';
icon.className = 'widget-bot-icon widget-bot-hover-ball-icon';
icon.onerror = () => {
// 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
if (icon.src !== defaultIconSrc) {
icon.src = defaultIconSrc;
} else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
applyButtonPosition(widgetButton, widgetInfo.btn_position || defaultBtnPosition);
// 添加事件监听器
widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown);
// 添加触摸事件支持
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton);
}
// 创建挂件按钮
function createWidget() {
// 如果已存在,先删除
if (widgetButton) {
widgetButton.remove();
}
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'hover_ball') {
createHoverBallButton();
} else {
createSideStickyButton();
}
// 创建模态框 // 创建模态框
createModal(); createModal();
@ -145,6 +267,109 @@
}, 100); }, 100);
} }
// 创建自定义触发按钮
function createCustomTrigger() {
const btnId = widgetInfo.btn_id;
if (!btnId) {
console.error('btn_trigger 模式需要提供 btn_id');
return;
}
let retryCount = 0;
const maxRetries = 50; // 最多重试 50 次5秒
// 绑定事件到元素
function attachTrigger(element) {
if (!element) return;
// 避免重复绑定
if (element.hasAttribute('data-widget-trigger-attached')) {
return;
}
element.setAttribute('data-widget-trigger-attached', 'true');
customTriggerElement = element;
// 创建事件处理函数并保存引用
customTriggerHandler = function (e) {
e.preventDefault();
e.stopPropagation();
showModal();
};
// 绑定点击事件
element.addEventListener('click', customTriggerHandler);
}
// 尝试查找并绑定元素
function tryAttachTrigger() {
const element = document.getElementById(btnId);
if (element) {
attachTrigger(element);
createModal();
return true;
}
return false;
}
// 立即尝试一次
if (tryAttachTrigger()) {
return;
}
// 如果元素还没加载,使用多种方式监听
function retryAttach() {
if (tryAttachTrigger()) {
return;
}
retryCount++;
if (retryCount < maxRetries) {
setTimeout(retryAttach, 100);
} else {
console.warn('自定义触发按钮未找到,已停止重试:', btnId);
}
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(function (mutations) {
if (tryAttachTrigger()) {
observer.disconnect();
}
});
// 开始观察 DOM 变化
observer.observe(document.body, {
childList: true,
subtree: true
});
// 如果 DOM 已加载完成,立即开始重试
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
setTimeout(retryAttach, 100);
});
} else {
setTimeout(retryAttach, 100);
}
// 延迟断开观察器(避免无限观察)
setTimeout(function () {
observer.disconnect();
}, 10000); // 10秒后断开
}
// 处理按钮点击事件(区分点击和拖拽)
function handleButtonClick(e) {
// 如果发生了拖拽,不打开弹框
if (hasDragged) {
e.preventDefault();
e.stopPropagation();
return;
}
showModal();
}
// 键盘事件处理 // 键盘事件处理
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@ -176,7 +401,8 @@
Math.pow(touch.clientY - touchStartPos.y, 2) Math.pow(touch.clientY - touchStartPos.y, 2)
); );
if (distance < 10) { // 只有在没有拖拽且移动距离很小的情况下才认为是点击
if (!hasDragged && distance < 10) {
// 判断为点击事件 // 判断为点击事件
setTimeout(() => showModal(), 100); setTimeout(() => showModal(), 100);
} }
@ -198,22 +424,41 @@
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title'); widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
widgetModal.setAttribute('data-theme', currentTheme); widgetModal.setAttribute('data-theme', currentTheme);
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
if (modalPosition === 'fixed') {
widgetModal.classList.add('widget-bot-modal-fixed');
}
const modalContent = document.createElement('div'); const modalContent = document.createElement('div');
modalContent.className = 'widget-bot-modal-content'; modalContent.className = 'widget-bot-modal-content';
if (modalPosition === 'fixed') {
modalContent.classList.add('widget-bot-modal-content-fixed');
}
// 创建关闭按钮 // 创建关闭按钮(透明框)
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.className = 'widget-bot-close-btn'; closeBtn.className = 'widget-bot-close-btn';
closeBtn.innerHTML = '<svg t="1752218667372" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4632" id="mx_n_1752218667373" width="32" height="32"><path d="M512 939.19762963a427.19762963 427.19762963 0 1 1 0-854.39525926 427.19762963 427.19762963 0 0 1 0 854.39525926z m0-482.08605274L396.47540505 341.53519999a19.41807408 19.41807408 0 0 0-27.44421216 0l-27.44421097 27.44421217a19.41807408 19.41807408 0 0 0 0 27.44421095L457.00801422 512l-115.47281423 115.52459495a19.41807408 19.41807408 0 0 0 0 27.44421216l27.44421217 27.44421097a19.41807408 19.41807408 0 0 0 27.44421095 0L512 566.99198578l115.52459495 115.47281423a19.41807408 19.41807408 0 0 0 27.44421216 0l27.44421097-27.44421217a19.41807408 19.41807408 0 0 0 0-27.44421095l-115.47281424-115.47281423 115.47281424-115.57637689a19.41807408 19.41807408 0 0 0 0-27.44421095l-27.44421097-27.44421096a19.41807408 19.41807408 0 0 0-27.44421216 0L512 457.00801422z" p-id="4633" fill="#ffffff"></path></svg>'
closeBtn.setAttribute('aria-label', '关闭窗口'); closeBtn.setAttribute('aria-label', '关闭窗口');
closeBtn.setAttribute('type', 'button'); closeBtn.setAttribute('type', 'button');
closeBtn.addEventListener('click', hideModal);
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 pointer-events: none
const closeBtnArea = document.createElement('div');
closeBtnArea.style.width = '100%';
closeBtnArea.style.height = '100%';
closeBtnArea.style.pointerEvents = 'auto'; // 内部元素可以接收事件
closeBtnArea.style.cursor = 'pointer';
closeBtnArea.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
hideModal();
});
closeBtn.appendChild(closeBtnArea);
// 创建iframe // 创建iframe
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.className = 'widget-bot-iframe'; iframe.className = 'widget-bot-iframe';
iframe.src = `${widgetDomain}/widget`; iframe.src = `${widgetDomain}/widget`;
iframe.setAttribute('title', `${widgetInfo.btn_text}服务窗口`); iframe.setAttribute('title', `${widgetInfo.btn_text || '在线客服'}服务窗口`);
iframe.setAttribute('allow', 'camera; microphone; geolocation'); iframe.setAttribute('allow', 'camera; microphone; geolocation');
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation'); iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
@ -224,6 +469,156 @@
document.body.appendChild(widgetModal); document.body.appendChild(widgetModal);
} }
// 检测是否为移动端
function isMobile() {
return window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// 智能定位弹框follow模式
function positionModalFollow(modalContent) {
if (!widgetButton || !modalContent) return;
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
requestAnimationFrame(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const margin = 16; // 距离屏幕边缘的最小距离
const buttonGap = 16; // 弹框和按钮之间的最小距离
// 先设置一个临时位置来获取弹框尺寸
const originalPosition = modalContent.style.position;
const originalTop = modalContent.style.top;
const originalLeft = modalContent.style.left;
const originalVisibility = modalContent.style.visibility;
const originalDisplay = modalContent.style.display;
modalContent.style.position = 'absolute';
modalContent.style.top = '0';
modalContent.style.left = '0';
modalContent.style.visibility = 'hidden';
modalContent.style.display = 'block';
const modalRect = modalContent.getBoundingClientRect();
const modalWidth = modalRect.width;
const modalHeight = modalRect.height;
modalContent.style.visibility = originalVisibility || 'visible';
modalContent.style.display = originalDisplay || 'block';
// 计算按钮中心点
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
// 判断按钮在屏幕的哪一侧
const isLeftSide = buttonCenterX < windowWidth / 2;
const isTopSide = buttonCenterY < windowHeight / 2;
// 智能选择弹框位置,确保完整显示
let finalTop, finalBottom, finalLeft, finalRight;
if (isLeftSide) {
// 按钮在左侧,弹框优先显示在右侧(按钮右侧)
finalLeft = buttonRect.right + buttonGap;
finalRight = 'auto';
// 如果右侧空间不够,显示在左侧(按钮左侧)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间也不够,则贴左边(但保持与按钮的距离)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalLeft = margin;
finalRight = 'auto';
}
}
} else {
// 按钮在右侧,弹框优先显示在左侧(按钮左侧)
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间不够,显示在右侧(按钮右侧)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalRight = 'auto';
finalLeft = buttonRect.right + buttonGap;
// 如果右侧空间也不够,则贴右边(但保持与按钮的距离)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = margin;
}
}
}
// 垂直方向:优先与按钮顶部对齐
// 弹框顶部与按钮顶部对齐
finalTop = buttonRect.top;
finalBottom = 'auto';
// 如果弹框底部超出屏幕,则向上调整,确保弹框完整显示在屏幕内
if (finalTop + modalHeight > windowHeight - margin) {
// 计算向上调整后的位置
const adjustedTop = windowHeight - margin - modalHeight;
// 如果调整后的位置仍然在按钮上方,则使用调整后的位置
if (adjustedTop >= margin) {
finalTop = adjustedTop;
} else {
// 如果调整后仍然超出,则贴顶部
finalTop = margin;
}
} else if (finalTop < margin) {
// 如果弹框顶部超出屏幕,则贴顶部
finalTop = margin;
}
// 应用最终位置
modalContent.style.top = finalTop !== undefined ? (typeof finalTop === 'string' ? finalTop : finalTop + 'px') : 'auto';
modalContent.style.bottom = finalBottom !== undefined ? (typeof finalBottom === 'string' ? finalBottom : finalBottom + 'px') : 'auto';
modalContent.style.left = finalLeft !== undefined ? (typeof finalLeft === 'string' ? finalLeft : finalLeft + 'px') : 'auto';
modalContent.style.right = finalRight !== undefined ? (typeof finalRight === 'string' ? finalRight : finalRight + 'px') : 'auto';
// 最终检查并修正,确保弹框完全在屏幕内
requestAnimationFrame(() => {
const finalModalRect = modalContent.getBoundingClientRect();
// 修正左边界
if (finalModalRect.left < margin) {
modalContent.style.left = margin + 'px';
modalContent.style.right = 'auto';
}
// 修正右边界
if (finalModalRect.right > windowWidth - margin) {
modalContent.style.right = margin + 'px';
modalContent.style.left = 'auto';
}
// 修正上边界
if (finalModalRect.top < margin) {
modalContent.style.top = margin + 'px';
modalContent.style.bottom = 'auto';
}
// 修正下边界
if (finalModalRect.bottom > windowHeight - margin) {
modalContent.style.bottom = margin + 'px';
modalContent.style.top = 'auto';
}
});
});
}
// 显示模态框 // 显示模态框
function showModal() { function showModal() {
if (!widgetModal) return; if (!widgetModal) return;
@ -231,27 +626,31 @@
widgetModal.style.display = 'flex'; widgetModal.style.display = 'flex';
document.body.classList.add('widget-bot-modal-open'); document.body.classList.add('widget-bot-modal-open');
// 计算模态框位置 const modalPosition = widgetInfo.modal_position || defaultModalPosition;
requestAnimationFrame(() => { const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
const buttonRect = widgetButton.getBoundingClientRect();
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
if (modalContent) { // 移动端强制居中显示
// 设置模态框位置距离按钮16px距离底部24px if (isMobile()) {
const modalBottom = 24; modalContent.style.position = 'relative';
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16); modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.bottom = modalBottom + 'px'; modalContent.style.right = 'auto';
modalContent.style.right = modalRight + 'px'; modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
// 确保模态框不会超出屏幕 modalContent.style.width = 'calc(100% - 32px)';
const modalRect = modalContent.getBoundingClientRect(); modalContent.style.height = 'auto';
if (modalRect.left < 16) { } else if (modalPosition === 'fixed') {
modalContent.style.right = '16px'; // 桌面端固定模式:居中展示
modalContent.style.left = '16px'; modalContent.style.position = 'relative';
} modalContent.style.top = 'auto';
} modalContent.style.left = 'auto';
}); modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
} else {
// 桌面端跟随模式:跟随按钮位置 - 智能定位,确保弹框完整显示在屏幕内
positionModalFollow(modalContent);
}
// 添加ESC键关闭功能 // 添加ESC键关闭功能
document.addEventListener('keydown', handleEscKey); document.addEventListener('keydown', handleEscKey);
@ -287,42 +686,98 @@
}; };
isDragging = true; isDragging = true;
hasDragged = false; // 重置拖拽标记
const rect = widgetButton.getBoundingClientRect(); const rect = widgetButton.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0].clientX); const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY); const clientY = e.clientY || (e.touches && e.touches[0].clientY);
dragOffset.x = clientX - rect.left; // 记录拖拽开始位置
dragOffset.y = clientY - rect.top; dragStartPos.x = clientX;
dragStartPos.y = clientY;
// 清除bottom定位使用top定位 // 缓存按钮尺寸,避免拖拽过程中频繁读取
widgetButton.style.bottom = 'auto'; buttonSize.width = rect.width;
widgetButton.style.top = rect.top + 'px'; buttonSize.height = rect.height;
// 先清除 transform确保获取真实的位置
widgetButton.style.transform = 'none';
// 重新获取位置(清除 transform 后的真实位置)
const realRect = widgetButton.getBoundingClientRect();
// 记录初始位置(基于清除 transform 后的真实位置)
initialPosition.left = realRect.left;
initialPosition.top = realRect.top;
dragOffset.x = clientX - realRect.left;
dragOffset.y = clientY - realRect.top;
// 确保使用 fixed 定位,使用真实位置
widgetButton.style.position = 'fixed'; widgetButton.style.position = 'fixed';
widgetButton.style.top = realRect.top + 'px';
widgetButton.style.left = realRect.left + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
document.addEventListener('mousemove', drag); // 禁用过渡效果,提升拖拽性能
widgetButton.style.transition = 'none';
// 提示浏览器优化(使用 left/top 定位)
widgetButton.style.willChange = 'left, top';
document.addEventListener('mousemove', drag, { passive: false });
document.addEventListener('mouseup', stopDrag); document.addEventListener('mouseup', stopDrag);
widgetButton.classList.add('dragging'); widgetButton.classList.add('dragging');
widgetButton.style.zIndex = '10001'; widgetButton.style.zIndex = '10001';
} }
// 拖拽中 // 拖拽中 - 直接更新位置,实现丝滑跟随
function drag(e) { function drag(e) {
if (!isDragging) return; if (!isDragging) return;
if (e.preventDefault) { if (e.preventDefault) {
e.preventDefault() e.preventDefault();
}; }
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY); const clientY = e.clientY || (e.touches && e.touches[0].clientY);
// 检测是否发生了实际移动超过5px才认为是拖拽
const moveDistance = Math.sqrt(
Math.pow(clientX - dragStartPos.x, 2) +
Math.pow(clientY - dragStartPos.y, 2)
);
if (moveDistance > 5) {
hasDragged = true;
}
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 直接基于鼠标位置计算新位置
// 鼠标位置减去拖拽偏移量,得到按钮左上角应该的位置
const newLeft = clientX - dragOffset.x;
const newTop = clientY - dragOffset.y; const newTop = clientY - dragOffset.y;
const maxTop = window.innerHeight - widgetButton.offsetHeight;
// 限制在屏幕范围内 // 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
const constrainedTop = Math.max(0, Math.min(newTop, maxTop)); const minTop = 24;
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
// 水平位置:限制在屏幕范围内
const maxLeft = windowWidth - buttonWidth;
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
// 直接使用 left/top 定位,实现无延迟的丝滑跟随
// 使用 transform: none 确保不会有任何 transform 干扰
widgetButton.style.left = constrainedLeft + 'px';
widgetButton.style.top = constrainedTop + 'px'; widgetButton.style.top = constrainedTop + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
widgetButton.style.transform = 'none';
} }
// 停止拖拽 // 停止拖拽
@ -330,26 +785,75 @@
if (!isDragging) return; if (!isDragging) return;
isDragging = false; isDragging = false;
// 取消待执行的动画帧
if (dragAnimationFrame) {
cancelAnimationFrame(dragAnimationFrame);
dragAnimationFrame = null;
}
document.removeEventListener('mousemove', drag); document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag); document.removeEventListener('mouseup', stopDrag);
widgetButton.classList.remove('dragging'); widgetButton.classList.remove('dragging');
widgetButton.style.zIndex = '9999'; widgetButton.style.zIndex = '9999';
// 吸附到右侧恢复bottom定位 // 恢复过渡效果
widgetButton.style.transition = '';
widgetButton.style.willChange = '';
// 根据按钮类型和当前位置进行最终定位
requestAnimationFrame(() => { requestAnimationFrame(() => {
const currentTop = parseInt(widgetButton.style.top); const buttonRect = widgetButton.getBoundingClientRect();
const currentLeft = buttonRect.left;
const currentTop = buttonRect.top;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const buttonHeight = widgetButton.offsetHeight; const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 计算距离底部的位置 // 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
const bottomPosition = windowHeight - currentTop - buttonHeight; // 根据按钮实际位置判断左右,保持当前位置
const screenCenterX = windowWidth / 2;
const buttonCenterX = currentLeft + buttonWidth / 2;
const isLeftSide = buttonCenterX < screenCenterX;
const sideDistance = 16; // 距离边缘的距离
// 恢复right和bottom定位清除top // 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 24px
widgetButton.style.right = '0'; const minTop = 24;
widgetButton.style.bottom = Math.max(20, bottomPosition) + 'px'; const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
widgetButton.style.top = 'auto'; const finalTop = Math.max(minTop, Math.min(currentTop, maxTop));
widgetButton.style.left = 'auto'; let finalLeft;
// 水平位置距离左右边16px
if (isLeftSide) {
finalLeft = sideDistance;
widgetButton.style.left = sideDistance + 'px';
widgetButton.style.right = 'auto';
} else {
finalLeft = windowWidth - sideDistance - buttonWidth;
widgetButton.style.right = sideDistance + 'px';
widgetButton.style.left = 'auto';
}
widgetButton.style.top = finalTop + 'px';
widgetButton.style.bottom = 'auto';
// 清除 transform使用 left/top 定位
widgetButton.style.transform = 'none';
// 更新 border-radius现在都是24px圆角
widgetButton.style.borderRadius = '24px';
// 更新初始位置,为下次拖拽做准备
if (finalLeft !== undefined && finalTop !== undefined) {
initialPosition.left = finalLeft;
initialPosition.top = finalTop;
} else {
// 如果未定义,使用当前实际位置
initialPosition.left = buttonRect.left;
initialPosition.top = buttonRect.top;
}
}); });
} }
@ -390,19 +894,30 @@
// 窗口大小改变时重新定位 // 窗口大小改变时重新定位
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
if (widgetModal && widgetModal.style.display === 'flex') { if (widgetModal && widgetModal.style.display === 'flex') {
// 重新计算模态框位置 const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
setTimeout(() => { if (!modalContent) return;
const buttonRect = widgetButton.getBoundingClientRect();
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
if (modalContent) { // 移动端强制居中显示
const modalBottom = 24; if (isMobile()) {
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16); modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
modalContent.style.bottom = modalBottom + 'px'; const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
modalContent.style.right = modalRight + 'px'; if (modalPosition === 'fixed') {
} // 固定居中模式不需要重新定位
}, 100); return;
}
// 重新计算模态框位置(使用智能定位)
positionModalFollow(modalContent);
} }
}); });
@ -423,8 +938,13 @@
if (widgetModal) { if (widgetModal) {
widgetModal.remove(); widgetModal.remove();
} }
if (customTriggerElement && customTriggerHandler) {
customTriggerElement.removeEventListener('click', customTriggerHandler);
customTriggerElement.removeAttribute('data-widget-trigger-attached');
}
}); });
// 启动 // 启动
init(); init();
})(); })();

View File

@ -4,7 +4,7 @@ import { ThemeStoreProvider } from '@/provider/themeStore';
import { getShareV1AppWebInfo } from '@/request/ShareApp'; import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth'; import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'; import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import localFont from 'next/font/local'; import localFont from 'next/font/local';
import { headers, cookies } from 'next/headers'; import { headers, cookies } from 'next/headers';
@ -92,7 +92,7 @@ const Layout = async ({
return ( return (
<html lang='en'> <html lang='en'>
<body <body
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : ''}`} className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : 'light'}`}
> >
<AppRouterCacheProvider> <AppRouterCacheProvider>
<ThemeStoreProvider themeMode={themeMode}> <ThemeStoreProvider themeMode={themeMode}>

View File

@ -49,7 +49,7 @@
--color-primary-main: #6e73fe; --color-primary-main: #6e73fe;
/* 代码块颜色 */ /* 代码块颜色 */
--code-bg: #ffffff; --code-bg: rgba(0, 0, 0, 0.03);
--code-color: #21222d; --code-color: #21222d;
--inline-code-bg: #fff5f5; --inline-code-bg: #fff5f5;
--inline-code-color: #ff502c; --inline-code-color: #ff502c;

View File

@ -1,8 +1,5 @@
import StoreProvider from '@/provider'; import StoreProvider from '@/provider';
import { darkThemeWidget, lightThemeWidget } from '@/theme';
import { getShareV1AppWidgetInfo } from '@/request/ShareApp'; import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
import { ThemeProvider } from '@ctzhian/ui';
import React from 'react'; import React from 'react';
const Layout = async ({ const Layout = async ({
@ -12,18 +9,7 @@ const Layout = async ({
}>) => { }>) => {
const widgetDetail: any = await getShareV1AppWidgetInfo(); const widgetDetail: any = await getShareV1AppWidgetInfo();
const themeMode = return <StoreProvider widget={widgetDetail}>{children}</StoreProvider>;
widgetDetail?.settings?.widget_bot_settings?.theme_mode || 'light';
return (
<ThemeProvider
theme={themeMode === 'dark' ? darkThemeWidget : lightThemeWidget}
>
<StoreProvider widget={widgetDetail} themeMode={themeMode || 'light'}>
{children}
</StoreProvider>
</ThemeProvider>
);
}; };
export default Layout; export default Layout;

View File

@ -1,17 +1,3 @@
import Widget from '@/views/widget'; import Widget from '@/views/widget';
import { Box } from '@mui/material';
const Page = () => { export default Widget;
return (
<Box
sx={{
width: '100vw',
height: '100vh',
}}
>
<Widget />
</Box>
);
};
export default Page;

View File

@ -109,10 +109,19 @@ export type WidgetInfo = {
search_placeholder: string; search_placeholder: string;
recommend_questions: string[]; recommend_questions: string[];
widget_bot_settings: { widget_bot_settings: {
btn_logo: string; btn_logo?: string;
btn_text: string; btn_text?: string;
is_open: boolean; btn_style?: string;
theme_mode: 'light' | 'dark'; btn_id?: string;
btn_position?: string;
modal_position?: string;
is_open?: boolean;
recommend_node_ids?: string[];
recommend_questions?: string[];
theme_mode?: string;
search_mode?: string;
placeholder?: string;
disclaimer?: string;
}; };
}; };
}; };

View File

@ -82,6 +82,8 @@ export interface ConversationItem {
message_id: string; message_id: string;
source: 'history' | 'chat'; source: 'history' | 'chat';
chunk_result: ChunkResultItem[]; chunk_result: ChunkResultItem[];
result_expend: boolean;
thinking_expend: boolean;
thinking_content: string; thinking_content: string;
id: string; id: string;
} }
@ -382,6 +384,8 @@ const AiQaContent: React.FC<{
const solution = await cap.solve(); const solution = await cap.solve();
token = solution.token; token = solution.token;
} catch (error) { } catch (error) {
setLoading(false);
setThinking(4);
message.error('验证失败'); message.error('验证失败');
console.log(error, 'error---------'); console.log(error, 'error---------');
return; return;
@ -465,6 +469,8 @@ const AiQaContent: React.FC<{
if (lastConversation) { if (lastConversation) {
lastConversation.a = answerContent; lastConversation.a = answerContent;
lastConversation.thinking_content = thinkingContent; lastConversation.thinking_content = thinkingContent;
lastConversation.result_expend = false;
lastConversation.thinking_expend = false;
} }
return newConversation; return newConversation;
}); });
@ -513,6 +519,8 @@ const AiQaContent: React.FC<{
source: 'chat', source: 'chat',
chunk_result: [], chunk_result: [],
thinking_content: '', thinking_content: '',
result_expend: true,
thinking_expend: true,
id: uuidv4(), id: uuidv4(),
}); });
messageIdRef.current = ''; messageIdRef.current = '';
@ -631,6 +639,8 @@ const AiQaContent: React.FC<{
source: 'history', source: 'history',
chunk_result: [], chunk_result: [],
thinking_content: '', thinking_content: '',
result_expend: true,
thinking_expend: true,
id: uuidv4(), id: uuidv4(),
}); });
} }
@ -667,6 +677,8 @@ const AiQaContent: React.FC<{
chunk_result: [], chunk_result: [],
thinking_content: '', thinking_content: '',
id: uuidv4(), id: uuidv4(),
result_expend: true,
thinking_expend: true,
}); });
} }
} }
@ -791,7 +803,16 @@ const AiQaContent: React.FC<{
<StyledAiBubble> <StyledAiBubble>
{/* 搜索结果 */} {/* 搜索结果 */}
{item.chunk_result.length > 0 && ( {item.chunk_result.length > 0 && (
<StyledChunkAccordion defaultExpanded> <StyledChunkAccordion
expanded={item.result_expend}
onChange={(event, expanded) => {
setConversation(prev => {
const newConversation = [...prev];
newConversation[index].result_expend = expanded;
return newConversation;
});
}}
>
<StyledChunkAccordionSummary <StyledChunkAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />} expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
> >
@ -837,7 +858,16 @@ const AiQaContent: React.FC<{
{/* 思考过程 */} {/* 思考过程 */}
{!!item.thinking_content && ( {!!item.thinking_content && (
<StyledThinkingAccordion defaultExpanded> <StyledThinkingAccordion
expanded={item.thinking_expend}
onChange={(event, expanded) => {
setConversation(prev => {
const newConversation = [...prev];
newConversation[index].thinking_expend = expanded;
return newConversation;
});
}}
>
<StyledThinkingAccordionSummary <StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />} expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
> >
@ -929,6 +959,9 @@ const AiQaContent: React.FC<{
</> </>
)} )}
</Stack> </Stack>
<Box>
{kbDetail?.settings?.disclaimer_settings?.content}
</Box>
</StyledActionStack> </StyledActionStack>
)} )}
</StyledAiBubble> </StyledAiBubble>

View File

@ -47,9 +47,9 @@ export const StyledUserBubble = styled(Box)(({ theme }) => ({
export const StyledAiBubble = styled(Box)(({ theme }) => ({ export const StyledAiBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-start', alignSelf: 'flex-start',
maxWidth: '85%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: '100%',
gap: theme.spacing(3), gap: theme.spacing(3),
})); }));

View File

@ -247,7 +247,9 @@ const QaModal: React.FC<QaModalProps> = () => {
<Box <Box
sx={{ sx={{
px: 3, px: 3,
pt: kbDetail?.settings?.disclaimer_settings?.content ? 2 : 0, pt: kbDetail?.settings?.web_app_custom_style?.show_brand_info
? 2
: 0,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@ -263,7 +265,10 @@ const QaModal: React.FC<QaModalProps> = () => {
gap: 1, gap: 1,
}} }}
> >
<Box>{kbDetail?.settings?.disclaimer_settings?.content}</Box> <Box>
{kbDetail?.settings?.web_app_custom_style?.show_brand_info &&
'本网站由 PandaWiki 提供技术支持'}
</Box>
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@ -1,10 +1,14 @@
'use client'; 'use client';
import Logo from '@/assets/images/logo.png'; import Logo from '@/assets/images/logo.png';
import { Box } from '@mui/material'; import { Stack, Box, IconButton, alpha, Tooltip } from '@mui/material';
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
import { IconDengchu } from '@panda-wiki/icons';
import { useStore } from '@/provider'; import { useStore } from '@/provider';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import ErrorIcon from '@mui/icons-material/Error';
import { Modal } from '@ctzhian/ui';
import { import {
Header as CustomHeader, Header as CustomHeader,
WelcomeHeader as WelcomeHeaderComponent, WelcomeHeader as WelcomeHeaderComponent,
@ -16,8 +20,58 @@ interface HeaderProps {
isWelcomePage?: boolean; isWelcomePage?: boolean;
} }
const LogoutButton = () => {
const [open, setOpen] = useState(false);
const handleLogout = () => {
return postShareProV1AuthLogout().then(() => {
// 使用当前页面的协议http 或 https
const protocol = window.location.protocol;
const host = window.location.host;
window.location.href = `${protocol}//${host}/auth/login`;
});
};
return (
<>
<Modal
title={
<Stack direction='row' alignItems='center' gap={1}>
<ErrorIcon sx={{ fontSize: 24, color: 'warning.main' }} />
<Box sx={{ mt: '2px' }}></Box>
</Stack>
}
open={open}
okText='确定'
cancelText='取消'
onCancel={() => setOpen(false)}
onOk={handleLogout}
closable={false}
>
<Box sx={{ pl: 4 }}>退</Box>
</Modal>
<Tooltip title='退出登录' arrow>
<IconButton size='small' onClick={() => setOpen(true)}>
<IconDengchu
sx={theme => ({
cursor: 'pointer',
color: alpha(theme.palette.text.primary, 0.65),
fontSize: 24,
'&:hover': { color: theme.palette.primary.main },
})}
/>
</IconButton>
</Tooltip>
</>
);
};
const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => { const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore(); const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const pathname = usePathname(); const pathname = usePathname();
const docWidth = useMemo(() => { const docWidth = useMemo(() => {
if (isWelcomePage) return 'full'; if (isWelcomePage) return 'full';
@ -55,16 +109,23 @@ const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
onSearch={handleSearch} onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)} onQaClick={() => setQaModalOpen?.(true)}
> >
<Box sx={{ ml: 2 }}> <Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
<ThemeSwitch /> <ThemeSwitch />
</Box> {!!authInfo && <LogoutButton />}
</Stack>
<QaModal /> <QaModal />
</CustomHeader> </CustomHeader>
); );
}; };
export const WelcomeHeader = () => { export const WelcomeHeader = () => {
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore(); const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => { const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
if (value?.trim()) { if (value?.trim()) {
if (type === 'chat') { if (type === 'chat') {
@ -91,6 +152,7 @@ export const WelcomeHeader = () => {
onSearch={handleSearch} onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)} onQaClick={() => setQaModalOpen?.(true)}
> >
<Box sx={{ ml: 2 }}>{!!authInfo && <LogoutButton />}</Box>
<QaModal /> <QaModal />
</WelcomeHeaderComponent> </WelcomeHeaderComponent>
); );

View File

@ -4,12 +4,12 @@ import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { styled, SvgIcon, SvgIconProps } from '@mui/material'; import { styled, SvgIcon, SvgIconProps } from '@mui/material';
// ==================== 图片数据缓存 ==================== // ==================== 图片数据缓存工具函数 ====================
// 全局图片 blob URL 缓存,避免重复请求 OSS
const imageBlobCache = new Map<string, string>();
// 下载图片并转换为 blob URL // 下载图片并转换为 blob URL
const fetchImageAsBlob = async (src: string): Promise<string> => { const fetchImageAsBlob = async (
src: string,
imageBlobCache: Map<string, string>,
): Promise<string> => {
// 检查缓存 // 检查缓存
if (imageBlobCache.has(src)) { if (imageBlobCache.has(src)) {
return imageBlobCache.get(src)!; return imageBlobCache.get(src)!;
@ -39,12 +39,8 @@ const fetchImageAsBlob = async (src: string): Promise<string> => {
} }
}; };
// 导出获取图片 blob URL 的函数 // 清理图片 blob 缓存
export const getImageBlobUrl = (src: string): string | null => { export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
return imageBlobCache.get(src) || null;
};
export const clearImageBlobCache = () => {
imageBlobCache.forEach(url => { imageBlobCache.forEach(url => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
@ -54,7 +50,7 @@ export const clearImageBlobCache = () => {
const StyledErrorContainer = styled('div')(({ theme }) => ({ const StyledErrorContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
padding: theme.spacing(2), padding: theme.spacing(1, 6),
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px', gap: '8px',
@ -71,7 +67,7 @@ const StyledErrorContainer = styled('div')(({ theme }) => ({
const StyledErrorText = styled('div')(() => ({ const StyledErrorText = styled('div')(() => ({
fontSize: '12px', fontSize: '12px',
marginBottom: 16, marginBottom: 10,
})); }));
export const ImageErrorIcon = (props: SvgIconProps) => { export const ImageErrorIcon = (props: SvgIconProps) => {
@ -102,7 +98,7 @@ export const ImageErrorIcon = (props: SvgIconProps) => {
const ImageErrorDisplay: React.FC = () => ( const ImageErrorDisplay: React.FC = () => (
<StyledErrorContainer> <StyledErrorContainer>
<ImageErrorIcon <ImageErrorIcon
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 160 }} sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 140 }}
/> />
<StyledErrorText></StyledErrorText> <StyledErrorText></StyledErrorText>
</StyledErrorContainer> </StyledErrorContainer>
@ -116,7 +112,7 @@ interface ImageComponentProps {
imageIndex: number; imageIndex: number;
onLoad: (index: number, html: string) => void; onLoad: (index: number, html: string) => void;
onError: (index: number, html: string) => void; onError: (index: number, html: string) => void;
onImageClick: (src: string) => void; imageBlobCache: Map<string, string>;
} }
// ==================== 图片组件 ==================== // ==================== 图片组件 ====================
@ -127,7 +123,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
imageIndex, imageIndex,
onLoad, onLoad,
onError, onError,
onImageClick, imageBlobCache,
}) => { }) => {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>( const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading', 'loading',
@ -149,7 +145,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
// 获取图片 blob URL // 获取图片 blob URL
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
fetchImageAsBlob(src) fetchImageAsBlob(src, imageBlobCache)
.then(url => { .then(url => {
if (mounted) { if (mounted) {
setBlobUrl(url); setBlobUrl(url);
@ -166,7 +162,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [src]); }, [src, imageBlobCache]);
// 解析自定义样式 // 解析自定义样式
const parseStyleString = (styleStr: string) => { const parseStyleString = (styleStr: string) => {
@ -238,7 +234,8 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
onClick={() => onImageClick(src)} // 传递原始 src 用于预览 data-original-src={src}
className='markdown-image'
{...getOtherProps()} {...getOtherProps()}
/> />
) : ( ) : (
@ -264,12 +261,13 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
export interface ImageRendererOptions { export interface ImageRendererOptions {
onImageLoad: (index: number, html: string) => void; onImageLoad: (index: number, html: string) => void;
onImageError: (index: number, html: string) => void; onImageError: (index: number, html: string) => void;
onImageClick: (src: string) => void;
imageRenderCache: Map<number, string>; imageRenderCache: Map<number, string>;
imageBlobCache: Map<string, string>;
} }
export const createImageRenderer = (options: ImageRendererOptions) => { export const createImageRenderer = (options: ImageRendererOptions) => {
const { onImageLoad, onImageError, imageRenderCache, onImageClick } = options; const { onImageLoad, onImageError, imageRenderCache, imageBlobCache } =
options;
return ( return (
src: string, src: string,
alt: string, alt: string,
@ -279,29 +277,6 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
// 检查缓存 // 检查缓存
const cached = imageRenderCache.get(imageIndex); const cached = imageRenderCache.get(imageIndex);
if (cached) { if (cached) {
// 下一帧对已缓存的DOM绑定原生点击事件避免事件丢失且不引起重渲染
requestAnimationFrame(() => {
const container = document.querySelector(
`.image-container-${imageIndex}`,
) as HTMLElement | null;
if (!container) return;
const img = container.querySelector('img') as HTMLImageElement | null;
if (!img) return;
const alreadyBound = (img as HTMLElement).getAttribute(
'data-click-bound',
);
if (!alreadyBound) {
(img as HTMLElement).setAttribute('data-click-bound', '1');
img.style.cursor = img.style.cursor || 'pointer';
img.addEventListener('click', () => {
try {
onImageClick(img.src);
} catch {
// noop
}
});
}
});
return cached; return cached;
} }
@ -323,7 +298,7 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
imageIndex={imageIndex} imageIndex={imageIndex}
onLoad={onImageLoad} onLoad={onImageLoad}
onError={onImageError} onError={onImageError}
onImageClick={onImageClick} imageBlobCache={imageBlobCache}
/>, />,
); );
} else { } else {

View File

@ -15,11 +15,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useSmartScroll } from '@/hooks'; import { useSmartScroll } from '@/hooks';
import { import { clearImageBlobCache, createImageRenderer } from './imageRenderer';
clearImageBlobCache,
createImageRenderer,
getImageBlobUrl,
} from './imageRenderer';
import { incrementalRender } from './incrementalRenderer'; import { incrementalRender } from './incrementalRenderer';
import { createMermaidRenderer } from './mermaidRenderer'; import { createMermaidRenderer } from './mermaidRenderer';
import { import {
@ -88,7 +84,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const lastContentRef = useRef<string>(''); const lastContentRef = useRef<string>('');
const mdRef = useRef<MarkdownIt | null>(null); const mdRef = useRef<MarkdownIt | null>(null);
const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map()); const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map());
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存 const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存HTML
const imageBlobCacheRef = useRef<Map<string, string>>(new Map()); // 图片 blob URL 缓存
// 使用智能滚动 hook // 使用智能滚动 hook
const { scrollToBottom } = useSmartScroll({ const { scrollToBottom } = useSmartScroll({
@ -125,13 +122,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
createImageRenderer({ createImageRenderer({
onImageLoad: handleImageLoad, onImageLoad: handleImageLoad,
onImageError: handleImageError, onImageError: handleImageError,
onImageClick: (src: string) => {
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = getImageBlobUrl(src);
setPreviewImgBlobUrl(blobUrl || src);
setPreviewOpen(true);
},
imageRenderCache: imageRenderCacheRef.current, imageRenderCache: imageRenderCacheRef.current,
imageBlobCache: imageBlobCacheRef.current,
}), }),
[handleImageLoad, handleImageError], [handleImageLoad, handleImageError],
); );
@ -158,6 +150,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const originalFenceRender = md.renderer.rules.fence; const originalFenceRender = md.renderer.rules.fence;
// 自定义图片渲染 // 自定义图片渲染
let imageCount = 0; let imageCount = 0;
let htmlImageCount = 0; // HTML 标签图片计数
let mermaidCount = 0; let mermaidCount = 0;
md.renderer.rules.image = (tokens, idx) => { md.renderer.rules.image = (tokens, idx) => {
imageCount++; imageCount++;
@ -240,6 +233,38 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
); );
}; };
// 解析 HTML img 标签并提取属性
const parseImgTag = (
html: string,
): {
src: string;
alt: string;
attrs: [string, string][];
} | null => {
// 匹配 <img> 标签(支持自闭合和普通标签)
const imgMatch = html.match(/<img\s+([^>]*?)\/?>/i);
if (!imgMatch) return null;
const attrsString = imgMatch[1];
const attrs: [string, string][] = [];
let src = '';
let alt = '';
// 解析属性:匹配 name="value" 或 name='value' 或 name=value
const attrRegex =
/(\w+)(?:=["']([^"']*)["']|=(?:["'])?([^\s>]+)(?:["'])?)?/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
const name = attrMatch[1].toLowerCase();
const value = attrMatch[2] || attrMatch[3] || '';
attrs.push([name, value]);
if (name === 'src') src = value;
if (name === 'alt') alt = value;
}
return { src, alt, attrs };
};
md.renderer.rules.html_block = ( md.renderer.rules.html_block = (
tokens, tokens,
idx, idx,
@ -278,6 +303,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
if (content.includes('<error>')) return '<span class="chat-error">'; if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>'; if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出 // 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) { if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content); return md.utils.escapeHtml(content);
@ -301,6 +341,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
if (content.includes('<error>')) return '<span class="chat-error">'; if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>'; if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出 // 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) { if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content); return md.utils.escapeHtml(content);
@ -352,7 +407,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
} }
}, [content, customizeRenderer, scrollToBottom]); }, [content, customizeRenderer, scrollToBottom]);
// 添加代码块点击复制功能 // 添加代码块点击复制和图片点击预览功能(事件代理)
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@ -360,6 +415,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// 检查是否点击了图片
const imgElement = target.closest(
'img.markdown-image',
) as HTMLImageElement;
if (imgElement) {
const originalSrc = imgElement.getAttribute('data-original-src');
if (originalSrc) {
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = imageBlobCacheRef.current.get(originalSrc);
setPreviewImgBlobUrl(blobUrl || originalSrc);
setPreviewOpen(true);
}
return;
}
// 检查是否点击了代码块 // 检查是否点击了代码块
const preElement = target.closest('pre.hljs'); const preElement = target.closest('pre.hljs');
if (preElement) { if (preElement) {
@ -368,6 +438,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const code = codeElement.textContent || ''; const code = codeElement.textContent || '';
copyText(code.replace(/\n$/, '')); copyText(code.replace(/\n$/, ''));
} }
return;
} }
// 检查是否点击了行内代码 // 检查是否点击了行内代码
@ -380,7 +451,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
container.addEventListener('click', handleClick); container.addEventListener('click', handleClick);
return () => { return () => {
clearImageBlobCache(); clearImageBlobCache(imageBlobCacheRef.current);
container.removeEventListener('click', handleClick); container.removeEventListener('click', handleClick);
}; };
}, []); }, []);
@ -406,6 +477,9 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
position: 'relative', position: 'relative',
display: 'inline-block', display: 'inline-block',
}, },
'.markdown-image': {
cursor: 'pointer',
},
'.image-error': { '.image-error': {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@ -1,95 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { getShareV1AppWidgetInfo } from './request/ShareApp';
import { middleware as homeMiddleware } from './middleware/home';
const proxyShare = async (request: NextRequest) => {
// 转发到 process.env.TARGET
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
const targetOrigin = process.env.TARGET!;
const targetUrl = new URL(
request.nextUrl.pathname + request.nextUrl.search,
targetOrigin,
);
// 构造 fetch 选项
const fetchHeaders = new Headers(request.headers);
fetchHeaders.set('x-kb-id', kb_id);
const fetchOptions: RequestInit = {
method: request.method,
headers: fetchHeaders,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
redirect: 'manual',
};
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
const nextRes = new NextResponse(proxyRes.body, {
status: proxyRes.status,
headers: proxyRes.headers,
statusText: proxyRes.statusText,
});
return nextRes;
};
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const pathname = url.pathname;
if (pathname.startsWith('/widget')) {
const widgetInfo: any = await getShareV1AppWidgetInfo();
if (widgetInfo) {
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
return NextResponse.rewrite(new URL('/not-fount', request.url));
}
}
return;
}
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
let needSetSessionId = false;
if (!sessionId) {
sessionId = uuidv4();
needSetSessionId = true;
}
let response: NextResponse;
if (pathname.startsWith('/share/')) {
response = await proxyShare(request);
} else {
response = await homeMiddleware(request, headers, sessionId);
}
if (needSetSessionId) {
response.cookies.set('x-pw-session-id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 年
});
}
if (!pathname.startsWith('/share')) {
response.headers.set('x-current-path', pathname);
response.headers.set('x-current-search', url.search);
}
return response;
}
export const config = {
matcher: [
'/',
'/home',
'/share/:path*',
'/chat/:path*',
'/widget',
'/welcome',
'/auth/login',
'/node/:path*',
'/node',
// '/client/:path*',
],
};

View File

@ -1,87 +0,0 @@
import { parsePathname } from '@/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { postShareV1StatPage } from '@/request/ShareStat';
import { getShareV1NodeList } from '@/request/ShareNode';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
import { deepSearchFirstNode } from '@/utils';
const StatPage = {
welcome: 1,
node: 2,
chat: 3,
auth: 4,
} as const;
const getFirstNode = async () => {
const nodeListResult: any = await getShareV1NodeList();
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
return deepSearchFirstNode(tree);
};
const getHomePath = async () => {
const info = await getShareV1AppWebInfo();
return info?.settings?.home_page_setting;
};
export async function middleware(
request: NextRequest,
headers: Record<string, string>,
session: string,
) {
const url = request.nextUrl.clone();
const { page, id } = parsePathname(url.pathname);
try {
// 获取节点列表
if (url.pathname === '/') {
const homePath = await getHomePath();
if (homePath === 'custom') {
return NextResponse.rewrite(new URL('/home', request.url));
} else {
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
if (firstNode) {
return NextResponse.rewrite(
new URL(`/node/${firstNode.id}`, request.url),
);
}
return NextResponse.rewrite(new URL('/node', request.url));
}
}
// 页面上报
const pages = Object.keys(StatPage);
if (pages.includes(page) || pages.includes(id)) {
postShareV1StatPage(
{
scene: StatPage[page as keyof typeof StatPage],
node_id: id || '',
},
{
headers: {
'x-pw-session-id': session,
...headers,
},
},
);
}
return NextResponse.next();
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
error.message === 'NEXT_REDIRECT'
) {
return NextResponse.redirect(
new URL(
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
request.url,
),
);
}
}
return NextResponse.next();
}

View File

@ -35,6 +35,10 @@ export const ThemeStoreProvider = ({
useEffect(() => { useEffect(() => {
Cookies.set('theme_mode', themeMode, { expires: 365 * 10 }); Cookies.set('theme_mode', themeMode, { expires: 365 * 10 });
}, [themeMode]); }, [themeMode]);
console.log('themeMode-------', themeMode);
console.log('themeMode-------', theme);
return ( return (
<ThemeContext.Provider value={{ themeMode, setThemeMode }}> <ThemeContext.Provider value={{ themeMode, setThemeMode }}>
<ThemeProvider theme={theme}>{children}</ThemeProvider> <ThemeProvider theme={theme}>{children}</ThemeProvider>

181
web/app/src/proxy.ts Normal file
View File

@ -0,0 +1,181 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { getShareV1AppWidgetInfo } from './request/ShareApp';
import { parsePathname } from '@/utils';
import { postShareV1StatPage } from '@/request/ShareStat';
import { getShareV1NodeList } from '@/request/ShareNode';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
import { deepSearchFirstNode } from '@/utils';
const StatPage = {
welcome: 1,
node: 2,
chat: 3,
auth: 4,
} as const;
const getFirstNode = async () => {
const nodeListResult: any = await getShareV1NodeList();
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
return deepSearchFirstNode(tree);
};
const getHomePath = async () => {
const info = await getShareV1AppWebInfo();
return info?.settings?.home_page_setting;
};
const homeProxy = async (
request: NextRequest,
headers: Record<string, string>,
session: string,
) => {
const url = request.nextUrl.clone();
const { page, id } = parsePathname(url.pathname);
try {
// 获取节点列表
if (url.pathname === '/') {
const homePath = await getHomePath();
if (homePath === 'custom') {
return NextResponse.rewrite(new URL('/home', request.url));
} else {
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
if (firstNode) {
return NextResponse.rewrite(
new URL(`/node/${firstNode.id}`, request.url),
);
}
return NextResponse.rewrite(new URL('/node', request.url));
}
}
// 页面上报
const pages = Object.keys(StatPage);
if (pages.includes(page) || pages.includes(id)) {
postShareV1StatPage(
{
scene: StatPage[page as keyof typeof StatPage],
node_id: id || '',
},
{
headers: {
'x-pw-session-id': session,
...headers,
},
},
);
}
return NextResponse.next();
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
error.message === 'NEXT_REDIRECT'
) {
return NextResponse.redirect(
new URL(
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
request.url,
),
);
}
}
return NextResponse.next();
};
const proxyShare = async (request: NextRequest) => {
// 转发到 process.env.TARGET
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
const targetOrigin = process.env.TARGET!;
const targetUrl = new URL(
request.nextUrl.pathname + request.nextUrl.search,
targetOrigin,
);
// 构造 fetch 选项
const fetchHeaders = new Headers(request.headers);
fetchHeaders.set('x-kb-id', kb_id);
const hasBody = !['GET', 'HEAD'].includes(request.method);
const fetchOptions: RequestInit = {
method: request.method,
headers: fetchHeaders,
body: hasBody ? request.body : undefined,
redirect: 'manual',
...(hasBody && { duplex: 'half' as const }),
};
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
const nextRes = new NextResponse(proxyRes.body, {
status: proxyRes.status,
headers: proxyRes.headers,
statusText: proxyRes.statusText,
});
return nextRes;
};
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const pathname = url.pathname;
if (pathname.startsWith('/widget')) {
const widgetInfo: any = await getShareV1AppWidgetInfo();
if (widgetInfo) {
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
}
return;
}
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
let needSetSessionId = false;
if (!sessionId) {
sessionId = uuidv4();
needSetSessionId = true;
}
let response: NextResponse;
if (pathname.startsWith('/share/')) {
response = await proxyShare(request);
} else {
response = await homeProxy(request, headers, sessionId);
}
if (needSetSessionId) {
response.cookies.set('x-pw-session-id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 年
});
}
if (!pathname.startsWith('/share')) {
response.headers.set('x-current-path', pathname);
response.headers.set('x-current-search', url.search);
}
return response;
}
export const config = {
matcher: [
'/',
'/home',
'/share/:path*',
'/chat/:path*',
'/widget',
'/welcome',
'/auth/login',
'/node/:path*',
'/node',
],
};

View File

@ -18,7 +18,6 @@ import {
DomainOpenAICompletionsResponse, DomainOpenAICompletionsResponse,
DomainResponse, DomainResponse,
PostShareV1ChatMessageParams, PostShareV1ChatMessageParams,
PostShareV1ChatWidgetParams,
} from "./types"; } from "./types";
/** /**
@ -92,28 +91,3 @@ export const postShareV1ChatMessage = (
format: "json", format: "json",
...params, ...params,
}); });
/**
* @description ChatWidget
*
* @tags share_chat
* @name PostShareV1ChatWidget
* @summary ChatWidget
* @request POST:/share/v1/chat/widget
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatWidget = (
query: PostShareV1ChatWidgetParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/widget`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -0,0 +1,75 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainChatRequest,
DomainChatSearchReq,
DomainChatSearchResp,
DomainResponse,
PostShareV1ChatWidgetParams,
} from "./types";
/**
* @description ChatWidget
*
* @tags Widget
* @name PostShareV1ChatWidget
* @summary ChatWidget
* @request POST:/share/v1/chat/widget
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatWidget = (
query: PostShareV1ChatWidgetParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/widget`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description WidgetSearch
*
* @tags Widget
* @name PostShareV1ChatWidgetSearch
* @summary WidgetSearch
* @request POST:/share/v1/chat/widget/search
* @response `200` `(DomainResponse & {
data?: DomainChatSearchResp,
})` OK
*/
export const postShareV1ChatWidgetSearch = (
request: DomainChatSearchReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: DomainChatSearchResp;
}
>({
path: `/share/v1/chat/widget/search`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -10,5 +10,6 @@ export * from './ShareNode'
export * from './ShareOpenapi' export * from './ShareOpenapi'
export * from './ShareStat' export * from './ShareStat'
export * from './Wechat' export * from './Wechat'
export * from './Widget'
export * from './types' export * from './types'

View File

@ -24,6 +24,7 @@ import {
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp, GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq, GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp, GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq, GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp, GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq, GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
@ -206,6 +207,32 @@ export const postShareProV1AuthLdap = (
...params, ...params,
}); });
/**
* @description
*
* @tags ShareAuth
* @name PostShareProV1AuthLogout
* @summary
* @request POST:/share/pro/v1/auth/logout
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
})` OK
*/
export const postShareProV1AuthLogout = (params: RequestParams = {}) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp;
}
>({
path: `/share/pro/v1/auth/logout`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
});
/** /**
* @description OAuth登录 * @description OAuth登录
* *

View File

@ -52,10 +52,12 @@ export enum ConstsSourceType {
export enum ConstsLicenseEdition { export enum ConstsLicenseEdition {
/** 开源版 */ /** 开源版 */
LicenseEditionFree = 0, LicenseEditionFree = 0,
/** 联创版 */ /** 专业版 */
LicenseEditionContributor = 1, LicenseEditionProfession = 1,
/** 企业版 */ /** 企业版 */
LicenseEditionEnterprise = 2, LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
} }
export enum ConstsContributeType { export enum ConstsContributeType {
@ -455,6 +457,11 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
any any
>; >;
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
string,
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq { export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
kb_id?: string; kb_id?: string;
redirect_url?: string; redirect_url?: string;
@ -669,8 +676,6 @@ export interface GetApiProV1TokenListParams {
} }
export interface PostApiV1LicensePayload { export interface PostApiV1LicensePayload {
/** license edition */
license_edition: "contributor" | "enterprise";
/** license type */ /** license type */
license_type: "file" | "code"; license_type: "file" | "code";
/** /**

View File

@ -171,14 +171,21 @@ export enum ConstsNodeAccessPerm {
NodeAccessPermClosed = "closed", NodeAccessPermClosed = "closed",
} }
export enum ConstsModelSettingMode {
ModelSettingModeManual = "manual",
ModelSettingModeAuto = "auto",
}
/** @format int32 */ /** @format int32 */
export enum ConstsLicenseEdition { export enum ConstsLicenseEdition {
/** 开源版 */ /** 开源版 */
LicenseEditionFree = 0, LicenseEditionFree = 0,
/** 联创版 */ /** 专业版 */
LicenseEditionContributor = 1, LicenseEditionProfession = 1,
/** 企业版 */ /** 企业版 */
LicenseEditionEnterprise = 2, LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
} }
export enum ConstsHomePageSetting { export enum ConstsHomePageSetting {
@ -922,6 +929,17 @@ export interface DomainMetricsConfig {
type?: string; type?: string;
} }
export interface DomainModelModeSetting {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
/** 手动模式下嵌入模型是否更新 */
is_manual_embedding_updated?: boolean;
/** 模式: manual 或 auto */
mode?: ConstsModelSettingMode;
}
export interface DomainMoveNodeReq { export interface DomainMoveNodeReq {
id: string; id: string;
kb_id: string; kb_id: string;
@ -1171,6 +1189,17 @@ export interface DomainShareConversationMessage {
role?: SchemaRoleType; role?: SchemaRoleType;
} }
export interface DomainShareNodeListItemResp {
emoji?: string;
id?: string;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
position?: number;
type?: DomainNodeType;
updated_at?: string;
}
export interface DomainSimpleAuth { export interface DomainSimpleAuth {
enabled?: boolean; enabled?: boolean;
password?: string; password?: string;
@ -1195,6 +1224,18 @@ export interface DomainStatPageReq {
scene: 1 | 2 | 3 | 4; scene: 1 | 2 | 3 | 4;
} }
export interface DomainSwitchModeReq {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
mode: "manual" | "auto";
}
export interface DomainSwitchModeResp {
message?: string;
}
export interface DomainTextConfig { export interface DomainTextConfig {
title?: string; title?: string;
type?: string; type?: string;
@ -1336,11 +1377,18 @@ export interface DomainWecomAIBotSettings {
} }
export interface DomainWidgetBotSettings { export interface DomainWidgetBotSettings {
btn_id?: string;
btn_logo?: string; btn_logo?: string;
btn_position?: string;
btn_style?: string;
btn_text?: string; btn_text?: string;
disclaimer?: string;
is_open?: boolean; is_open?: boolean;
modal_position?: string;
placeholder?: string;
recommend_node_ids?: string[]; recommend_node_ids?: string[];
recommend_questions?: string[]; recommend_questions?: string[];
search_mode?: string;
theme_mode?: string; theme_mode?: string;
} }
@ -1646,6 +1694,7 @@ export interface V1ShareNodeDetailResp {
editor_id?: string; editor_id?: string;
id?: string; id?: string;
kb_id?: string; kb_id?: string;
list?: DomainShareNodeListItemResp[];
meta?: DomainNodeMeta; meta?: DomainNodeMeta;
name?: string; name?: string;
parent_id?: string; parent_id?: string;

View File

@ -117,7 +117,7 @@ const DocContent = ({
setCommentImages([]); setCommentImages([]);
message.success( message.success(
appDetail?.web_app_comment_settings?.moderation_enable appDetail?.web_app_comment_settings?.moderation_enable
? '正在审核中...' ? '评论已提交,请耐心等待审核'
: '评论成功', : '评论成功',
); );
} catch (error: any) { } catch (error: any) {

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More