Compare commits

...

8 Commits

Author SHA1 Message Date
holly a65026472c
Merge 56cc980226 into 63a4a964b1 2025-11-14 10:06:31 +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
64 changed files with 1206 additions and 1197 deletions

View File

@ -1,8 +1,6 @@
package consts
import (
"math"
"github.com/labstack/echo/v4"
)
@ -13,28 +11,13 @@ const ContextKeyEdition contextKey = "edition"
type LicenseEdition int32
const (
LicenseEditionFree LicenseEdition = 0 // 开源版
LicenseEditionContributor LicenseEdition = 1 // 联创版
LicenseEditionEnterprise LicenseEdition = 2 // 企业版
LicenseEditionFree LicenseEdition = 0 // 开源版
LicenseEditionProfession LicenseEdition = 1 // 专业版
LicenseEditionEnterprise LicenseEdition = 2 // 企业版
LicenseEditionBusiness LicenseEdition = 3 // 商业版
)
func GetLicenseEdition(c echo.Context) LicenseEdition {
edition, _ := c.Get("edition").(LicenseEdition)
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

@ -4067,22 +4067,26 @@ const docTemplate = `{
"enum": [
0,
1,
2
2,
3
],
"x-enum-comments": {
"LicenseEditionContributor": "联创版",
"LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版"
"LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
},
"x-enum-descriptions": [
"开源版",
"联创版",
"企业版"
"专业版",
"企业版",
"商业版"
],
"x-enum-varnames": [
"LicenseEditionFree",
"LicenseEditionContributor",
"LicenseEditionEnterprise"
"LicenseEditionProfession",
"LicenseEditionEnterprise",
"LicenseEditionBusiness"
]
},
"consts.ModelSettingMode": {

View File

@ -4060,22 +4060,26 @@
"enum": [
0,
1,
2
2,
3
],
"x-enum-comments": {
"LicenseEditionContributor": "联创版",
"LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版"
"LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
},
"x-enum-descriptions": [
"开源版",
"联创版",
"企业版"
"专业版",
"企业版",
"商业版"
],
"x-enum-varnames": [
"LicenseEditionFree",
"LicenseEditionContributor",
"LicenseEditionEnterprise"
"LicenseEditionProfession",
"LicenseEditionEnterprise",
"LicenseEditionBusiness"
]
},
"consts.ModelSettingMode": {

View File

@ -117,20 +117,24 @@ definitions:
- 0
- 1
- 2
- 3
format: int32
type: integer
x-enum-comments:
LicenseEditionContributor: 联创
LicenseEditionBusiness: 商业
LicenseEditionEnterprise: 企业版
LicenseEditionFree: 开源版
LicenseEditionProfession: 专业版
x-enum-descriptions:
- 开源版
- 联创
- 专业
- 企业版
- 商业版
x-enum-varnames:
- LicenseEditionFree
- LicenseEditionContributor
- LicenseEditionProfession
- LicenseEditionEnterprise
- LicenseEditionBusiness
consts.ModelSettingMode:
enum:
- manual

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

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

View File

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

View File

@ -46,7 +46,7 @@ func NewModelHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *
return handler
}
// get model list
// GetModelList
//
// @Summary get model list
// @Description get model list
@ -66,7 +66,7 @@ func (h *ModelHandler) GetModelList(c echo.Context) error {
return h.NewResponseWithData(c, models)
}
// create model
// CreateModel
//
// @Summary create model
// @Description create model
@ -85,9 +85,6 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
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()
param := domain.ModelParam{}
@ -112,7 +109,7 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithData(c, model)
}
// update model
// UpdateModel
//
// @Description update model
// @Tags model
@ -130,9 +127,6 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
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()
if err := h.usecase.Update(ctx, &req); err != nil {
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)
}
// check model
// CheckModel
//
// @Summary 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 {
return h.NewResponseWithError(c, "validate request body failed", err)
}
req.MaxNode = 300
if maxNode := c.Get("max_node"); maxNode != nil {
req.MaxNode = maxNode.(int)
}
req.MaxNode = domain.GetBaseEditionLimitation(ctx).MaxNode
id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId)
if err != nil {
if errors.Is(err, domain.ErrMaxNodeLimitReached) {
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到联创版或企业版", nil)
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到更高版本", nil)
}
return h.NewResponseWithError(c, "create node failed", err)
}

View File

@ -15,7 +15,7 @@ type AuthMiddleware interface {
Authorize(next echo.HandlerFunc) echo.HandlerFunc
ValidateUserRole(role consts.UserRole) 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)
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"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(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{
Success: false,
Message: "Unauthorized ValidateLicenseEdition",

@ -1 +1 @@
Subproject commit c4dc498df094cb617d31c95580db8239a445d652
Subproject commit bcf7e0f0bedb18f43cf36463ddb45ace6c1dbab9

View File

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

View File

@ -26,12 +26,12 @@ func (r *CommentRepository) CreateComment(ctx context.Context, comment *domain.C
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
var comments []*domain.ShareCommentListItem
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
}
@ -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) {
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
if req.Status == nil {
if err := query.Count(&count).Error; err != nil {
return nil, 0, err
}
} else {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionEnterprise {
if domain.GetBaseEditionLimitation(ctx).AllowCommentAudit {
query = query.Where("comments.status = ?", *req.Status)
}
// 按照时间排序来查询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 {
// 批量删除指定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 {
return err

View File

@ -341,11 +341,12 @@ func (r *KnowledgeBaseRepository) CreateKnowledgeBase(ctx context.Context, maxKB
Name: kb.Name,
Type: domain.AppTypeWeb,
Settings: domain.AppSettings{
Title: kb.Name,
Desc: kb.Name,
Keyword: kb.Name,
Icon: domain.DefaultPandaWikiIconB64,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name),
Title: kb.Name,
Desc: kb.Name,
Keyword: kb.Name,
AutoSitemap: true,
Icon: domain.DefaultPandaWikiIconB64,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name),
Btns: []any{
AppBtn{
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.release_id = ?", kbRelease.ID).
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 {
return nil, err
}

View File

@ -60,18 +60,14 @@ func (r *UserRepository) CreateUser(ctx context.Context, user *domain.User, edit
}
user.Password = string(hashedPassword)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionFree {
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
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")
}
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
return err
}
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 {
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 {
switch edition {
case consts.LicenseEditionFree:
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.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)
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
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
}
@ -618,8 +622,8 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
}
showBrand := true
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.DisclaimerSettings.Content = &defaultDisclaimer
} else {

View File

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

View File

@ -407,7 +407,7 @@ func (u *NodeUsecase) GetNodePermissionsByID(ctx context.Context, id, kbID strin
}
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 {
return domain.ErrPermissionDenied
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,14 @@
import HelpCenter from '@/assets/json/help-center.json';
import IconUpgrade from '@/assets/json/upgrade.json';
import LottieIcon from '@/components/LottieIcon';
import { EditionType } from '@/constant/enums';
import { useAppSelector } from '@/store';
import { Box, Stack, Tooltip } from '@mui/material';
import { useEffect, useState } from 'react';
import packageJson from '../../../package.json';
import AuthTypeModal from './AuthTypeModal';
import freeVersion from '@/assets/images/free-version.png';
import enterpriseVersion from '@/assets/images/enterprise-version.png';
import contributorVersion from '@/assets/images/contributor-version.png';
const versionMap = {
0: freeVersion,
1: contributorVersion,
2: enterpriseVersion,
};
import { useVersionInfo } from '@/hooks';
const Version = () => {
const { license } = useAppSelector(state => state.config);
const versionInfo = useVersionInfo();
const curVersion = import.meta.env.VITE_APP_VERSION || packageJson.version;
const [latestVersion, setLatestVersion] = useState<string | undefined>(
undefined,
@ -57,11 +47,8 @@ const Version = () => {
>
<Stack direction={'row'} alignItems='center' gap={0.5}>
<Box sx={{ width: 30, color: 'text.tertiary' }}></Box>
<img
src={versionMap[license.edition!]}
style={{ height: 13, marginTop: -1 }}
/>
{EditionType[license.edition as keyof typeof EditionType].text}
<img src={versionInfo.image} style={{ height: 13, marginTop: -1 }} />
{versionInfo.label}
</Stack>
<Stack direction={'row'} gap={0.5}>
<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 { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
import { ConstsUserKBPermission, V1KBUserInviteReq } from '@/request/types';
import { ConstsLicenseEdition } from '@/request/pro/types';
@ -26,9 +27,13 @@ const VERSION_MAP = {
message: '开源版只支持 1 个管理员',
max: 1,
},
[ConstsLicenseEdition.LicenseEditionContributor]: {
message: '联创版最多支持 3 个管理员',
max: 3,
[ConstsLicenseEdition.LicenseEditionProfession]: {
message: '专业版最多支持 20 个管理员',
max: 20,
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
message: '商业版最多支持 50 个管理员',
max: 50,
},
};
@ -45,9 +50,6 @@ const MemberAdd = ({
const { kbList, license, refreshAdminRequest } = useAppSelector(
state => state.config,
);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const {
control,
@ -118,6 +120,10 @@ const MemberAdd = ({
});
});
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
return (
<>
<Button
@ -253,6 +259,14 @@ const MemberAdd = ({
fullWidth
displayEmpty
sx={{ height: 52 }}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: 1,
color: 'text.disabled',
},
},
}}
renderValue={(value: V1KBUserInviteReq['perm']) => {
return value ? (
PERM_MAP[value]
@ -266,17 +280,25 @@ const MemberAdd = ({
>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem>
</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: '其他',
};
export const Free = 0;
export const Contributor = 1;
export const Enterprise = 2;
export const EditionType = {
[Free]: {
text: '开源版',
},
[Contributor]: {
text: '联创版',
},
[Enterprise]: {
text: '企业版',
},
};
export const DocWidth = {
full: {
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 { useCommitPendingInput } from './useCommitPendingInput';
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 { useEffect, useState } from 'react';
import DocModal from './DocModal';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { useURLSearchParams } from '@/hooks';
import {
@ -46,7 +48,7 @@ const statusColorMap = {
} as const;
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 page = Number(searchParams.get('page') || '1');
const pageSize = Number(searchParams.get('page_size') || '20');
@ -283,111 +285,114 @@ export default function ContributionPage() {
};
useEffect(() => {
if (kb_id) getData();
if (kb_id && PROFESSION_VERSION_PERMISSION.includes(license.edition!))
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, nodeNameParam, authNameParam, kb_id]);
}, [page, pageSize, nodeNameParam, authNameParam, kb_id, license.edition]);
return (
<Card>
<Stack
direction='row'
alignItems={'center'}
justifyContent={'space-between'}
sx={{ p: 2 }}
>
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
<TextField
fullWidth
size='small'
label='文档'
value={searchDoc}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ node_name: searchDoc || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ node_name: e.target.value, page: '1' });
}}
onChange={e => setSearchDoc(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
fullWidth
size='small'
label='用户'
value={searchUser}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ auth_name: searchUser || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ auth_name: e.target.value, page: '1' });
}}
onChange={e => setSearchUser(e.target.value)}
sx={{ width: 200 }}
/>
</StyledSearchRow>
</Stack>
<Table
columns={columns}
dataSource={data}
rowKey='id'
height='calc(100vh - 148px)'
size='small'
sx={{
overflow: 'hidden',
...tableSx,
'.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,
<VersionMask permission={PROFESSION_VERSION_PERMISSION}>
<Stack
direction='row'
alignItems={'center'}
justifyContent={'space-between'}
sx={{ p: 2 }}
>
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
<TextField
fullWidth
size='small'
label='文档'
value={searchDoc}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ node_name: searchDoc || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ node_name: e.target.value, page: '1' });
}}
onChange={e => setSearchDoc(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
fullWidth
size='small'
label='用户'
value={searchUser}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ auth_name: searchUser || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ auth_name: e.target.value, page: '1' });
}}
onChange={e => setSearchUser(e.target.value)}
sx={{ width: 200 }}
/>
</StyledSearchRow>
</Stack>
<Table
columns={columns}
dataSource={data}
rowKey='id'
height='calc(100vh - 148px)'
size='small'
sx={{
overflow: 'hidden',
...tableSx,
'.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,
},
},
}}
/>
{previewRow?.meta?.content_type === 'md' ? (
<MarkdownPreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
{previewRow?.meta?.content_type === 'md' ? (
<MarkdownPreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
</VersionMask>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
defaultValues: {
desc: '',
keyword: '',
auto_sitemap: false,
},
});
@ -44,7 +43,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
useEffect(() => {
setValue('desc', data.settings?.desc || '');
setValue('keyword', data.settings?.keyword || '');
setValue('auto_sitemap', data.settings?.auto_sitemap ?? false);
}, [data]);
return (
@ -88,25 +86,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
)}
/>
</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>
);
};

View File

@ -1,7 +1,9 @@
import Card from '@/components/Card';
import { ConstsLicenseEdition } from '@/request/types';
import InfoIcon from '@mui/icons-material/Info';
import { Button, Stack, styled, SxProps, Tooltip } from '@mui/material';
import { createContext, useContext } from 'react';
import VersionMask from '@/components/VersionMask';
const StyledForm = styled('form')<{ gap?: number | string }>(
({ theme, gap = 2 }) => ({
@ -40,6 +42,7 @@ const StyledFormLabel = styled('span')<{ required?: boolean }>(
export const StyledFormItem = styled('div')<{ vertical?: boolean }>(
({ theme, vertical }) => ({
position: 'relative',
display: 'flex',
alignItems: vertical ? 'flex-start' : 'center',
flexDirection: vertical ? 'column' : 'row',
@ -82,6 +85,7 @@ export const FormItem = ({
extra,
sx,
labelSx,
permission,
}: {
label?: string | React.ReactNode;
children?: React.ReactNode;
@ -92,31 +96,37 @@ export const FormItem = ({
extra?: React.ReactNode;
sx?: SxProps;
labelSx?: SxProps;
permission?: number[];
}) => {
const { vertical: verticalContext, labelWidth: labelWidthContext } =
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}
</StyledFormLabelWrapper>
{children}
</StyledFormItem>
return (
<VersionMask permission={permission}>
<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}
</StyledFormLabelWrapper>
{children}
</StyledFormItem>
</VersionMask>
);
};
@ -142,6 +152,7 @@ export const SettingCard = ({
};
const StyledSettingCardItem = styled('div')(({ theme }) => ({
position: 'relative',
'&:not(:last-child)': {
borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(4),
@ -204,6 +215,12 @@ export const SettingCardItem = ({
extra,
more,
sx,
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
}: {
children?: React.ReactNode;
title?: React.ReactNode;
@ -212,6 +229,7 @@ export const SettingCardItem = ({
extra?: React.ReactNode;
more?: SettingCardItemMore;
sx?: SxProps;
permission?: number[];
}) => {
const renderMore = (more: SettingCardItemMore) => {
if (more && typeof more === 'object' && 'type' in more) {
@ -237,20 +255,23 @@ export const SettingCardItem = ({
return more;
}
};
return (
<StyledSettingCardItem sx={sx}>
<StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemTitle>
{title} {renderMore(more)}
</StyledSettingCardItemTitle>
{isEdit && (
<Button variant='contained' size='small' onClick={onSubmit}>
</Button>
)}
{extra}
</StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemContent>{children}</StyledSettingCardItemContent>
</StyledSettingCardItem>
<VersionMask permission={permission}>
<StyledSettingCardItem sx={sx}>
<StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemTitle>
{title} {renderMore(more)}
</StyledSettingCardItemTitle>
{isEdit && (
<Button variant='contained' size='small' onClick={onSubmit}>
</Button>
)}
{extra}
</StyledSettingCardItemTitleWrapper>
<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 { Tooltip } from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import { Modal, message } from '@ctzhian/ui';
import { Stack, Button } from '@mui/material';
import { Box } from '@mui/material';
import { postApiProV1AuthGroupSync } from '@/request/pro/AuthOrg';
import {
@ -19,6 +16,7 @@ import {
deleteApiProV1AuthGroupDelete,
} from '@/request/pro/AuthGroup';
import GroupTree from './GroupTree';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
interface UserGroupProps {
enabled: string;
@ -45,10 +43,6 @@ const UserGroup = ({
GithubComChaitinPandaWikiProApiAuthV1AuthGroupTreeItem[]
>([]);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const onDeleteUserGroup = (id: number) => {
Modal.confirm({
title: '删除用户组',
@ -74,10 +68,15 @@ const UserGroup = ({
});
};
useEffect(() => {
if (!kb_id || enabled !== '2' || !isEnterprise) return;
if (
!kb_id ||
enabled !== '2' ||
!BUSINESS_VERSION_PERMISSION.includes(license.edition!)
)
return;
getUserGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kb_id, enabled, isEnterprise]);
}, [kb_id, enabled, license.edition!]);
const handleMove = async ({
id,
@ -123,32 +122,7 @@ const UserGroup = ({
};
return (
<SettingCardItem
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>
// )
// }
>
<SettingCardItem title='用户组' permission={BUSINESS_VERSION_PERMISSION}>
<Box
sx={{
border: '1px dashed',

View File

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

View File

@ -26,6 +26,8 @@ import {
GetApiV1NodeListParams,
GetApiV1NodeRecommendNodesParams,
V1NodeDetailResp,
V1NodeRestudyReq,
V1NodeRestudyResp,
} from "./types";
/**
@ -263,6 +265,38 @@ export const getApiV1NodeRecommendNodes = (
...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
*

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 './Node'
export * from './NodePermission'
export * from './NodeRestudy'
export * from './Stat'
export * from './User'
export * from './types'

View File

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

View File

@ -175,10 +175,12 @@ export enum ConstsNodeAccessPerm {
export enum ConstsLicenseEdition {
/** 开源版 */
LicenseEditionFree = 0,
/** 联创版 */
LicenseEditionContributor = 1,
/** 专业版 */
LicenseEditionProfession = 1,
/** 企业版 */
LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
}
export enum ConstsHomePageSetting {
@ -1645,8 +1647,9 @@ export interface V1NodePermissionResp {
}
export interface V1NodeRestudyReq {
kb_id?: string;
node_ids?: string[];
kb_id: string;
/** @minItems 1 */
node_ids: string[];
}
export type V1NodeRestudyResp = Record<string, any>;

View File

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

View File

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

View File

@ -1,117 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import dayjs from 'dayjs';
import { ConversationItem } from '../types';
import { ChunkResultItem } from '@/assets/type';
export const useConversation = () => {
const [conversation, setConversation] = useState<ConversationItem[]>([]);
const [fullAnswer, setFullAnswer] = useState<string>('');
const [chunkResult, setChunkResult] = useState<ChunkResultItem[]>([]);
const [thinkingContent, setThinkingContent] = useState<string>('');
const [answer, setAnswer] = useState('');
const [isChunkResult, setIsChunkResult] = useState(false);
const [isThinking, setIsThinking] = useState(false);
const messageIdRef = useRef('');
const addQuestion = useCallback(
(q: string, reset: boolean = false) => {
const newConversation = reset
? []
: conversation.some(item => item.source === 'history')
? []
: [...conversation];
newConversation.push({
q,
a: '',
score: 0,
message_id: '',
update_time: '',
source: 'chat',
chunk_result: [],
thinking_content: '',
});
messageIdRef.current = '';
setConversation(newConversation);
setChunkResult([]);
setThinkingContent('');
setAnswer('');
setFullAnswer('');
},
[conversation],
);
const updateLastConversation = useCallback(() => {
setAnswer(prevAnswer => {
setThinkingContent(prevThinkingContent => {
setChunkResult(prevChunkResult => {
setConversation(prev => {
const newConversation = [...prev];
const lastConversation =
newConversation[newConversation.length - 1];
if (lastConversation) {
lastConversation.a = prevAnswer;
lastConversation.update_time = dayjs().format(
'YYYY-MM-DD HH:mm:ss',
);
lastConversation.message_id = messageIdRef.current;
lastConversation.source = 'chat';
lastConversation.chunk_result = prevChunkResult;
lastConversation.thinking_content = prevThinkingContent;
}
return newConversation;
});
return prevChunkResult;
});
return prevThinkingContent;
});
return '';
});
setFullAnswer('');
}, []);
const updateConversationScore = useCallback(
(message_id: string, score: number) => {
setConversation(prev =>
prev.map(item =>
item.message_id === message_id ? { ...item, score } : item,
),
);
},
[],
);
const resetConversation = useCallback(() => {
setConversation([]);
setChunkResult([]);
setAnswer('');
setFullAnswer('');
setThinkingContent('');
messageIdRef.current = '';
}, []);
return {
conversation,
setConversation,
fullAnswer,
setFullAnswer,
chunkResult,
setChunkResult,
thinkingContent,
setThinkingContent,
answer,
setAnswer,
isChunkResult,
setIsChunkResult,
isThinking,
setIsThinking,
messageIdRef,
addQuestion,
updateLastConversation,
updateConversationScore,
resetConversation,
};
};

View File

@ -1,112 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { message } from '@ctzhian/ui';
import { UploadedImage } from '../types';
import { MAX_IMAGES, MAX_IMAGE_SIZE } from '../constants';
export const useImageUpload = () => {
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const cleanupImageUrls = useCallback((images: UploadedImage[]) => {
images.forEach(img => {
if (img.url.startsWith('blob:')) {
URL.revokeObjectURL(img.url);
}
});
}, []);
const handleImageSelect = useCallback(
async (files: FileList | null) => {
if (!files || files.length === 0) return;
const remainingSlots = MAX_IMAGES - uploadedImages.length;
if (remainingSlots <= 0) {
message.warning(`最多只能上传 ${MAX_IMAGES} 张图片`);
return;
}
const filesToAdd = Array.from(files).slice(0, remainingSlots);
const newImages: UploadedImage[] = [];
for (const file of filesToAdd) {
if (!file.type.startsWith('image/')) {
message.error('只支持上传图片文件');
continue;
}
if (file.size > MAX_IMAGE_SIZE) {
message.error('图片大小不能超过 10MB');
continue;
}
const localUrl = URL.createObjectURL(file);
newImages.push({
id: Date.now().toString() + Math.random(),
url: localUrl,
file,
});
}
setUploadedImages(prev => [...prev, ...newImages]);
},
[uploadedImages.length],
);
const handleImageUpload = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
handleImageSelect(event.target.files);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[handleImageSelect],
);
const handleRemoveImage = useCallback((id: string) => {
setUploadedImages(prev => {
const imageToRemove = prev.find(img => img.id === id);
if (imageToRemove && imageToRemove.url.startsWith('blob:')) {
URL.revokeObjectURL(imageToRemove.url);
}
return prev.filter(img => img.id !== id);
});
}, []);
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLDivElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
const dataTransfer = new DataTransfer();
imageFiles.forEach(file => dataTransfer.items.add(file));
await handleImageSelect(dataTransfer.files);
}
},
[handleImageSelect],
);
const clearImages = useCallback(() => {
cleanupImageUrls(uploadedImages);
setUploadedImages([]);
}, [uploadedImages, cleanupImageUrls]);
return {
uploadedImages,
fileInputRef,
handleImageUpload,
handleRemoveImage,
handlePaste,
clearImages,
};
};

View File

@ -1,217 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { message } from '@ctzhian/ui';
import SSEClient from '@/utils/fetch';
import { handleThinkingContent } from '../utils';
import { SSEMessageData, ChatRequestData } from '../types';
import { AnswerStatusType, CAP_CONFIG, SSE_CONFIG } from '../constants';
import dayjs from 'dayjs';
interface UseSSEChatProps {
conversationId: string;
setConversationId: React.Dispatch<React.SetStateAction<string>>;
nonce: string;
setNonce: React.Dispatch<React.SetStateAction<string>>;
messageIdRef: React.MutableRefObject<string>;
setFullAnswer: React.Dispatch<React.SetStateAction<string>>;
setAnswer: React.Dispatch<React.SetStateAction<string>>;
setThinkingContent: React.Dispatch<React.SetStateAction<string>>;
setChunkResult: React.Dispatch<React.SetStateAction<any[]>>;
setConversation: React.Dispatch<React.SetStateAction<any[]>>;
setIsChunkResult: (value: boolean) => void;
setIsThinking: (value: boolean) => void;
setThinking: (value: AnswerStatusType) => void;
setLoading: (value: boolean) => void;
scrollToBottom: () => void;
}
export const useSSEChat = ({
conversationId,
setConversationId,
nonce,
setNonce,
messageIdRef,
setFullAnswer,
setAnswer,
setThinkingContent,
setChunkResult,
setConversation,
setIsChunkResult,
setIsThinking,
setThinking,
setLoading,
scrollToBottom,
}: UseSSEChatProps) => {
const sseClientRef = useRef<SSEClient<SSEMessageData> | null>(null);
const initializeSSE = useCallback(() => {
sseClientRef.current = new SSEClient({
url: SSE_CONFIG.url,
headers: SSE_CONFIG.headers,
onCancel: () => {
setLoading(false);
setThinking(4);
setAnswer(prev => {
let value = '';
if (prev) {
value = prev + '\n\n<error>Request canceled</error>';
}
setConversation(prev => {
const newConversation = [...prev];
if (newConversation[newConversation.length - 1]) {
newConversation[newConversation.length - 1].a = value;
newConversation[newConversation.length - 1].update_time =
dayjs().format('YYYY-MM-DD HH:mm:ss');
newConversation[newConversation.length - 1].message_id =
messageIdRef.current;
}
return newConversation;
});
return '';
});
},
});
}, [messageIdRef, setAnswer, setConversation, setLoading, setThinking]);
const chatAnswer = useCallback(
async (q: string) => {
setLoading(true);
setThinking(1);
let token = '';
try {
const Cap = (await import('@cap.js/widget')).default;
const cap = new Cap({ apiEndpoint: CAP_CONFIG.apiEndpoint });
const solution = await cap.solve();
token = solution.token;
} catch (error) {
message.error('验证失败');
console.error('Captcha error:', error);
setLoading(false);
return;
}
const reqData: ChatRequestData = {
message: q,
nonce: nonce || '',
conversation_id: conversationId || '',
app_type: 1,
captcha_token: token,
};
if (sseClientRef.current) {
sseClientRef.current.subscribe(
JSON.stringify(reqData),
({ type, content, chunk_result }) => {
if (type === 'conversation_id') {
setConversationId(prev => prev + content);
} else if (type === 'message_id') {
messageIdRef.current += content;
} else if (type === 'nonce') {
setNonce(prev => prev + content);
} else if (type === 'error') {
setLoading(false);
setIsChunkResult(false);
setIsThinking(false);
setThinking(4);
setAnswer(prev => {
if (content) {
return prev + `\n\n回答出现错误<error>${content}</error>`;
}
return prev + '\n\n回答出现错误请重试';
});
if (content) message.error(content);
} else if (type === 'done') {
setAnswer(prevAnswer => {
setThinkingContent(prevThinkingContent => {
setChunkResult(prevChunkResult => {
setConversation(prev => {
const newConversation = [...prev];
const lastConversation =
newConversation[newConversation.length - 1];
if (lastConversation) {
lastConversation.a = prevAnswer;
lastConversation.update_time = dayjs().format(
'YYYY-MM-DD HH:mm:ss',
);
lastConversation.message_id = messageIdRef.current;
lastConversation.source = 'chat';
lastConversation.chunk_result = prevChunkResult;
lastConversation.thinking_content = prevThinkingContent;
}
return newConversation;
});
return prevChunkResult;
});
return prevThinkingContent;
});
return '';
});
setFullAnswer('');
setLoading(false);
setIsChunkResult(false);
setIsThinking(false);
setThinking(4);
} else if (type === 'data') {
setIsChunkResult(false);
setFullAnswer(prevFullAnswer => {
const newFullAnswer = prevFullAnswer + content;
const { thinkingContent, answerContent } =
handleThinkingContent(newFullAnswer);
setThinkingContent(thinkingContent);
setAnswer(answerContent);
if (newFullAnswer.includes('</think>')) {
setIsThinking(false);
setThinking(3);
} else if (newFullAnswer.includes('<think>')) {
setIsThinking(true);
setThinking(2);
} else {
setThinking(3);
}
return newFullAnswer;
});
} else if (type === 'chunk_result') {
setChunkResult(prev => [...prev, chunk_result]);
setIsChunkResult(true);
setTimeout(scrollToBottom, 200);
}
},
);
}
},
[
conversationId,
nonce,
messageIdRef,
setConversationId,
setNonce,
setLoading,
setThinking,
setAnswer,
setFullAnswer,
setThinkingContent,
setChunkResult,
setConversation,
setIsChunkResult,
setIsThinking,
scrollToBottom,
],
);
const handleSearchAbort = useCallback(() => {
sseClientRef.current?.unsubscribe();
setLoading(false);
setThinking(4);
}, [setLoading, setThinking]);
return {
sseClientRef,
initializeSSE,
chatAnswer,
handleSearchAbort,
};
};

View File

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