Expose image studio flow and related admin controls

Bundle the current backend and web changes into one publishable snapshot
from the server worktree so the remote mirror reflects the deployed source.

Constraint: Push the existing mixed worktree from /docker/new-api/src without reshaping files
Rejected: Split into feature-specific commits | current worktree is already a single undifferentiated snapshot
Confidence: medium
Scope-risk: moderate
Directive: Split future feature work before deployment to preserve clearer history
Tested: git status inspection; git diff --stat review
Not-tested: build, lint, unit tests, integration tests
This commit is contained in:
OpenClaw Task Bot
2026-05-05 12:16:54 +08:00
parent 58c0258362
commit 92ee465a41
23 changed files with 2694 additions and 202 deletions

View File

@@ -6,7 +6,7 @@ COPY web/bun.lock .
RUN bun install RUN bun install
COPY ./web . COPY ./web .
COPY ./VERSION . COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=3072" VITE_REACT_APP_VERSION=$(cat VERSION) bun run build RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=4096" VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0 ENV GO111MODULE=on CGO_ENABLED=0

View File

@@ -1,38 +0,0 @@
FROM oven/bun:latest AS builder
WORKDIR /build
COPY web/package.json .
COPY web/bun.lock .
RUN bun install
COPY ./web .
COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
FROM golang:alpine AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS
ARG TARGETARCH
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
ENV GOEXPERIMENT=greenteagc
WORKDIR /build
ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
COPY --from=builder2 /build/new-api /
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]

View File

@@ -39,8 +39,10 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
} }
} }
if IsImageGenerationModel(modelName) { if IsImageGenerationModel(modelName) {
// add to first
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
} }
if IsImageEditModel(modelName) {
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageEdit}, endpointTypes...)
}
return endpointTypes return endpointTypes
} }

View File

@@ -13,10 +13,16 @@ var (
"dall-e-3", "dall-e-3",
"dall-e-2", "dall-e-2",
"gpt-image-1", "gpt-image-1",
"gpt-image-2",
"prefix:imagen-", "prefix:imagen-",
"flux-", "flux-",
"flux.1-", "flux.1-",
} }
ImageEditModels = []string{
"gpt-image-1",
"gpt-image-2",
"prefix:qwen-image-edit",
}
OpenAITextModels = []string{ OpenAITextModels = []string{
"gpt-", "gpt-",
"o1", "o1",
@@ -48,6 +54,19 @@ func IsImageGenerationModel(modelName string) bool {
return false return false
} }
func IsImageEditModel(modelName string) bool {
modelName = strings.ToLower(modelName)
for _, m := range ImageEditModels {
if strings.Contains(modelName, m) {
return true
}
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
return true
}
}
return false
}
func IsOpenAITextModel(modelName string) bool { func IsOpenAITextModel(modelName string) bool {
modelName = strings.ToLower(modelName) modelName = strings.ToLower(modelName)
for _, m := range OpenAITextModels { for _, m := range OpenAITextModels {

1087
controller/image_studio.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,10 @@ const (
SecureVerificationSessionKey = "secure_verified_at" SecureVerificationSessionKey = "secure_verified_at"
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification. // PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at" PasskeyReadySessionKey = "secure_passkey_ready_at"
// secureVerificationMethodSessionKey stores the method that completed step-up verification.
secureVerificationMethodSessionKey = "secure_verification_method"
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
// SecureVerificationTimeout 验证有效期(秒) // SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟 SecureVerificationTimeout = 300 // 5分钟
// PasskeyReadyTimeout passkey ready 标记有效期(秒) // PasskeyReadyTimeout passkey ready 标记有效期(秒)
@@ -77,6 +81,7 @@ func UniversalVerify(c *gin.Context) {
// 根据验证方式进行验证 // 根据验证方式进行验证
var verified bool var verified bool
var verifyMethod string var verifyMethod string
var sessionMethod string
var err error var err error
switch req.Method { switch req.Method {
@@ -91,6 +96,7 @@ func UniversalVerify(c *gin.Context) {
} }
verified = validateTwoFactorAuth(twoFA, req.Code) verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA" verifyMethod = "2FA"
sessionMethod = secureVerificationMethod2FA
case "passkey": case "passkey":
if !hasPasskey { if !hasPasskey {
@@ -108,6 +114,7 @@ func UniversalVerify(c *gin.Context) {
return return
} }
verifyMethod = "Passkey" verifyMethod = "Passkey"
sessionMethod = secureVerificationMethodPasskey
default: default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
@@ -120,7 +127,7 @@ func UniversalVerify(c *gin.Context) {
} }
// 验证成功,在 session 中记录时间戳 // 验证成功,在 session 中记录时间戳
now, err := setSecureVerificationSession(c) now, err := setSecureVerificationSession(c, sessionMethod)
if err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return return
@@ -139,11 +146,16 @@ func UniversalVerify(c *gin.Context) {
}) })
} }
func setSecureVerificationSession(c *gin.Context) (int64, error) { func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
session := sessions.Default(c) session := sessions.Default(c)
session.Delete(PasskeyReadySessionKey) session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix() now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now) session.Set(SecureVerificationSessionKey, now)
if method != "" {
session.Set(secureVerificationMethodSessionKey, method)
} else {
session.Delete(secureVerificationMethodSessionKey)
}
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
return 0, err return 0, err
} }

View File

@@ -475,6 +475,12 @@ func generateDefaultSidebarConfig(userRole int) string {
"personal": true, "personal": true,
} }
// 其他功能区域 - 所有用户都可以访问
defaultConfig["other"] = map[string]interface{}{
"enabled": true,
"image_studio": true,
}
// 管理员区域 - 根据角色决定 // 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser { if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置 // 管理员可以访问管理员区域,但不能访问系统设置

View File

@@ -291,10 +291,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = c.Param("model") modelRequest.Model = c.Param("model")
} }
} }
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") || strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
contentType := c.ContentType() contentType := c.ContentType()
if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) { if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) {
req, err := getModelFromRequest(c) req, err := getModelFromRequest(c)
@@ -302,6 +299,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = req.Model modelRequest.Model = req.Model
} }
} }
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
}
} }
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
relayMode := relayconstant.RelayModeAudioSpeech relayMode := relayconstant.RelayModeAudioSpeech

View File

@@ -160,6 +160,8 @@ func InitOptionMap() {
common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled) common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled)
common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled) common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled)
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString()
common.OptionMap["ImageStudioBannedWords"] = setting.ImageStudioBannedWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
@@ -497,6 +499,10 @@ func updateOptionMap(key string, value string) (err error) {
common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64) common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
case "SensitiveWords": case "SensitiveWords":
setting.SensitiveWordsFromString(value) setting.SensitiveWordsFromString(value)
case "ImageStudioSafetyPrompt":
setting.ImageStudioSafetyPromptFromString(value)
case "ImageStudioBannedWords":
setting.ImageStudioBannedWordsFromString(value)
case "AutomaticDisableKeywords": case "AutomaticDisableKeywords":
operation_setting.AutomaticDisableKeywordsFromString(value) operation_setting.AutomaticDisableKeywordsFromString(value)
case "AutomaticDisableStatusCodes": case "AutomaticDisableStatusCodes":

View File

@@ -434,7 +434,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode { switch info.RelayMode {
case relayconstant.RelayModeImagesEdits: case relayconstant.RelayModeImagesEdits, relayconstant.RelayModeImagesGenerations:
if info.RelayMode == relayconstant.RelayModeImagesGenerations && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
return request, nil
}
var requestBody bytes.Buffer var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody) writer := multipart.NewWriter(&requestBody)
@@ -602,7 +605,8 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
if info.RelayMode == relayconstant.RelayModeAudioTranscription || if info.RelayMode == relayconstant.RelayModeAudioTranscription ||
info.RelayMode == relayconstant.RelayModeAudioTranslation || info.RelayMode == relayconstant.RelayModeAudioTranslation ||
info.RelayMode == relayconstant.RelayModeImagesEdits { info.RelayMode == relayconstant.RelayModeImagesEdits ||
(info.RelayMode == relayconstant.RelayModeImagesGenerations && strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data")) {
return channel.DoFormRequest(a, c, info, requestBody) return channel.DoFormRequest(a, c, info, requestBody)
} else if info.RelayMode == relayconstant.RelayModeRealtime { } else if info.RelayMode == relayconstant.RelayModeRealtime {
return channel.DoWssRequest(a, c, info, requestBody) return channel.DoWssRequest(a, c, info, requestBody)

View File

@@ -143,11 +143,11 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
imageRequest := &dto.ImageRequest{} imageRequest := &dto.ImageRequest{}
switch relayMode { switch relayMode {
case relayconstant.RelayModeImagesEdits: case relayconstant.RelayModeImagesEdits, relayconstant.RelayModeImagesGenerations:
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
_, err := c.MultipartForm() _, err := c.MultipartForm()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err) return nil, fmt.Errorf("failed to parse image form request: %w", err)
} }
formData := c.Request.PostForm formData := c.Request.PostForm
imageRequest.Prompt = formData.Get("prompt") imageRequest.Prompt = formData.Get("prompt")

View File

@@ -260,6 +260,19 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.POST("/batch", controller.DeleteTokenBatch) tokenRoute.POST("/batch", controller.DeleteTokenBatch)
} }
imageStudioRoute := apiRouter.Group("/image-studio")
imageStudioRoute.Use(middleware.UserAuth())
{
imageStudioRoute.POST("/generate", controller.SubmitImageStudioGenerate)
imageStudioRoute.POST("/edit", controller.SubmitImageStudioEdit)
imageStudioRoute.GET("/task/:id", controller.GetImageStudioTask)
}
imageStudioResultRoute := apiRouter.Group("/image-studio")
imageStudioResultRoute.Use(middleware.TokenOrUserAuth())
{
imageStudioResultRoute.GET("/task/:id/result/:index", controller.GetImageStudioTaskResult)
}
usageRoute := apiRouter.Group("/usage") usageRoute := apiRouter.Group("/usage")
usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
{ {

View File

@@ -17,6 +17,7 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
router.Use(gzip.Gzip(gzip.DefaultCompression)) router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit()) router.Use(middleware.GlobalWebRateLimit())
router.Use(middleware.Cache()) router.Use(middleware.Cache())
router.GET("/image-studio-results/*filepath", controller.GetImageStudioResultFile)
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist"))) router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
router.NoRoute(func(c *gin.Context) { router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web") c.Set(middleware.RouteTagKey, "web")

View File

@@ -13,25 +13,136 @@ var StopOnSensitiveEnabled = true
// StreamCacheQueueLength 流模式缓存队列长度0表示无缓存 // StreamCacheQueueLength 流模式缓存队列长度0表示无缓存
var StreamCacheQueueLength = 0 var StreamCacheQueueLength = 0
const defaultImageStudioSafetyPrompt = `你是一个严格的图片生成安全审核器。
如果用户提示词包含或试图引导生成以下内容,必须直接拒绝:
1. 暴力、血腥、伤害、虐待、自残、自杀、恐怖主义、战争、武器、尸体、残肢、令人极度恶心的内容。
2. 色情、裸露、性暗示、卖淫、约炮、成人视频、强奷、SM、性暴力、性服务相关内容。
3. 赌博、博彩、赌场、下注、下注网站、洗钱、毒品、制毒、吸毒、贩毒、违禁药物。
4. 中国政治敏感、反动、分裂、颠覆、煽动、侮辱国家象征、攻击国家领导人、违法集会、暴恐宣传等内容。
5. 任何试图绕过上述限制的变体、谐音、拆字、缩写、暗语、拼音、外语翻译或角色扮演指令。
如果命中任何一项,直接拒绝,不要改写,不要生成,不要解释如何绕过。`
const defaultImageStudioBannedWords = `暴力
血腥
伤害
虐待
自残
自杀
恐怖
恐怖主义
战争
武器
枪支
炸弹
尸体
残肢
恶心
色情
裸露
性暗示
卖淫
约炮
成人视频
SM
性暴力
性服务
赌博
博彩
赌场
下注
洗钱
毒品
制毒
吸毒
贩毒
违禁药物
政治敏感
反动
分裂
颠覆
煽动
国家领导人
国旗
国徽
国歌
暴恐
porn
nude
nudity
sex
sexual
erotic
nsfw
gore
bloody
blood
violence
violent
suicide
self-harm
terrorist
terrorism
gambling
casino
drug
drugs
cocaine
heroin
meth`
var ImageStudioSafetyPrompt = strings.TrimSpace(defaultImageStudioSafetyPrompt)
var ImageStudioBannedWords = splitLines(defaultImageStudioBannedWords)
// SensitiveWords 敏感词 // SensitiveWords 敏感词
// var SensitiveWords []string // var SensitiveWords []string
var SensitiveWords = []string{ var SensitiveWords = []string{
"test_sensitive", "test_sensitive",
} }
func splitLines(s string) []string {
items := make([]string, 0)
for _, item := range strings.Split(s, "\n") {
item = strings.TrimSpace(item)
if item != "" {
items = append(items, item)
}
}
return items
}
func SensitiveWordsToString() string { func SensitiveWordsToString() string {
return strings.Join(SensitiveWords, "\n") return strings.Join(SensitiveWords, "\n")
} }
func SensitiveWordsFromString(s string) { func SensitiveWordsFromString(s string) {
SensitiveWords = []string{} SensitiveWords = splitLines(s)
sw := strings.Split(s, "\n")
for _, w := range sw {
w = strings.TrimSpace(w)
if w != "" {
SensitiveWords = append(SensitiveWords, w)
} }
func ImageStudioSafetyPromptToString() string {
return ImageStudioSafetyPrompt
} }
func ImageStudioSafetyPromptFromString(s string) {
prompt := strings.TrimSpace(s)
if prompt == "" {
ImageStudioSafetyPrompt = strings.TrimSpace(defaultImageStudioSafetyPrompt)
return
}
ImageStudioSafetyPrompt = prompt
}
func ImageStudioBannedWordsToString() string {
return strings.Join(ImageStudioBannedWords, "\n")
}
func ImageStudioBannedWordsFromString(s string) {
words := splitLines(s)
if len(words) == 0 {
ImageStudioBannedWords = splitLines(defaultImageStudioBannedWords)
return
}
ImageStudioBannedWords = words
} }
func ShouldCheckPromptSensitive() bool { func ShouldCheckPromptSensitive() bool {

11
web/src/App.jsx vendored
View File

@@ -47,6 +47,7 @@ import Playground from './pages/Playground';
import Subscription from './pages/Subscription'; import Subscription from './pages/Subscription';
import OAuth2Callback from './components/auth/OAuth2Callback'; import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting'; import PersonalSetting from './components/settings/PersonalSetting';
import ImageStudio from './pages/ImageStudio';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck'; import SetupCheck from './components/layout/SetupCheck';
@@ -277,6 +278,16 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path='/console/image-studio'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<ImageStudio />
</Suspense>
</PrivateRoute>
}
/>
<Route <Route
path='/console/log' path='/console/log'
element={ element={

View File

@@ -49,6 +49,7 @@ const routerMap = {
deployment: '/console/deployment', deployment: '/console/deployment',
playground: '/console/playground', playground: '/console/playground',
personal: '/console/personal', personal: '/console/personal',
image_studio: '/console/image-studio',
}; };
const SiderBar = ({ onNavigate = () => {} }) => { const SiderBar = ({ onNavigate = () => {} }) => {
@@ -145,6 +146,18 @@ const SiderBar = ({ onNavigate = () => {} }) => {
return filteredItems; return filteredItems;
}, [t, isModuleVisible]); }, [t, isModuleVisible]);
const otherItems = useMemo(() => {
const items = [
{
text: t('图片制作'),
itemKey: 'image_studio',
to: '/image-studio',
},
];
return items.filter((item) => isModuleVisible('other', item.itemKey));
}, [t, isModuleVisible]);
const adminItems = useMemo(() => { const adminItems = useMemo(() => {
const items = [ const items = [
{ {
@@ -475,6 +488,19 @@ const SiderBar = ({ onNavigate = () => {} }) => {
</> </>
)} )}
{/* 其他功能区域 */}
{hasSectionVisibleModules('other') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('其他功能')}</div>
)}
{otherItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */} {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
{isAdmin() && hasSectionVisibleModules('admin') && ( {isAdmin() && hasSectionVisibleModules('admin') && (
<> <>

View File

@@ -63,36 +63,9 @@ const NotificationSettings = ({
// 左侧边栏设置相关状态 // 左侧边栏设置相关状态
const [sidebarLoading, setSidebarLoading] = useState(false); const [sidebarLoading, setSidebarLoading] = useState(false);
const [activeTabKey, setActiveTabKey] = useState('notification'); const [activeTabKey, setActiveTabKey] = useState('notification');
const [sidebarModulesUser, setSidebarModulesUser] = useState({ const [sidebarModulesUser, setSidebarModulesUser] = useState(
chat: { mergeAdminConfig(null),
enabled: true, );
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
subscription: true,
redemption: true,
user: true,
setting: true,
},
});
const [adminConfig, setAdminConfig] = useState(null); const [adminConfig, setAdminConfig] = useState(null);
// 使用后端权限验证替代前端角色判断 // 使用后端权限验证替代前端角色判断
@@ -155,29 +128,7 @@ const NotificationSettings = ({
}; };
const resetSidebarModules = () => { const resetSidebarModules = () => {
const defaultConfig = { setSidebarModulesUser(mergeAdminConfig(null));
chat: { enabled: true, playground: true, chat: true },
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: { enabled: true, topup: true, personal: true },
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
subscription: true,
redemption: true,
user: true,
setting: true,
},
};
setSidebarModulesUser(defaultConfig);
}; };
// 加载左侧边栏配置 // 加载左侧边栏配置
@@ -207,7 +158,7 @@ const NotificationSettings = ({
} else { } else {
userConf = userRes.data.data.sidebar_modules; userConf = userRes.data.data.sidebar_modules;
} }
setSidebarModulesUser(userConf); setSidebarModulesUser(mergeAdminConfig(userConf));
} }
} catch (error) { } catch (error) {
console.error('加载边栏配置失败:', error); console.error('加载边栏配置失败:', error);
@@ -287,6 +238,18 @@ const NotificationSettings = ({
], ],
}, },
// 管理员区域:根据后端权限控制显示 // 管理员区域:根据后端权限控制显示
{
key: 'other',
title: t('其他功能区域'),
description: t('图片与扩展功能'),
modules: [
{
key: 'image_studio',
title: t('图片制作'),
description: t('生成图片和编辑图片'),
},
],
},
{ {
key: 'admin', key: 'admin',
title: t('管理员区域'), title: t('管理员区域'),

View File

@@ -75,6 +75,7 @@ import {
Package, Package,
Server, Server,
CalendarClock, CalendarClock,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { import {
SiAtlassian, SiAtlassian,
@@ -146,6 +147,8 @@ export function getLucideIcon(key, selected = false) {
return <CalendarClock {...commonProps} color={iconColor} />; return <CalendarClock {...commonProps} color={iconColor} />;
case 'setting': case 'setting':
return <Settings {...commonProps} color={iconColor} />; return <Settings {...commonProps} color={iconColor} />;
case 'image_studio':
return <Sparkles {...commonProps} color={iconColor} />;
default: default:
return <CircleUser {...commonProps} color={iconColor} />; return <CircleUser {...commonProps} color={iconColor} />;
} }

View File

@@ -121,13 +121,12 @@ if (isMobileScreen) {
export function showError(error) { export function showError(error) {
console.error(error); console.error(error);
if (error.message) { if (error?.message) {
if (error.name === 'AxiosError') { if (error.name === 'AxiosError') {
switch (error.response.status) { const status = error.response?.status;
switch (status) {
case 401: case 401:
// 清除用户状态
localStorage.removeItem('user'); localStorage.removeItem('user');
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
window.location.href = '/login?expired=true'; window.location.href = '/login?expired=true';
break; break;
case 429: case 429:

View File

@@ -44,6 +44,10 @@ export const DEFAULT_ADMIN_CONFIG = {
topup: true, topup: true,
personal: true, personal: true,
}, },
other: {
enabled: true,
image_studio: true,
},
admin: { admin: {
enabled: true, enabled: true,
channel: true, channel: true,

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ export default function SettingsSensitiveWords(props) {
CheckSensitiveEnabled: false, CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false, CheckSensitiveOnPromptEnabled: false,
SensitiveWords: '', SensitiveWords: '',
ImageStudioSafetyPrompt: '',
}); });
const refForm = useRef(); const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs); const [inputsRow, setInputsRow] = useState(inputs);
@@ -130,8 +131,8 @@ export default function SettingsSensitiveWords(props) {
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.TextArea <Form.TextArea
label={t('屏蔽词列表')} label={t('屏蔽词列表')}
extraText={t('一行一个屏蔽词,不需要符号分割')} extraText={t('一行一个屏蔽词,不需要符号分割。该词库也会用于图片生图提示词审核')}
placeholder={t('一行一个屏蔽词,不需要符号分割')} placeholder={t('一行一个屏蔽词,不需要符号分割。该词库也会用于图片生图提示词审核')}
field={'SensitiveWords'} field={'SensitiveWords'}
onChange={(value) => onChange={(value) =>
setInputs({ setInputs({
@@ -144,6 +145,24 @@ export default function SettingsSensitiveWords(props) {
/> />
</Col> </Col>
</Row> </Row>
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.TextArea
label={t('图片生图安全提示词')}
extraText={t('仅用于图片生成/编辑,请写入暴力、色情、赌博、毒品、中国政治敏感、恐怖、恶心等禁止内容约束')}
placeholder={t('仅用于图片生成/编辑,请写入暴力、色情、赌博、毒品、中国政治敏感、恐怖、恶心等禁止内容约束')}
field={'ImageStudioSafetyPrompt'}
onChange={(value) =>
setInputs({
...inputs,
ImageStudioSafetyPrompt: value,
})
}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 10, maxRows: 20 }}
/>
</Col>
</Row>
<Row> <Row>
<Button size='default' onClick={onSubmit}> <Button size='default' onClick={onSubmit}>
{t('保存屏蔽词过滤设置')} {t('保存屏蔽词过滤设置')}

View File

@@ -30,6 +30,7 @@ import {
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { API, showSuccess, showError } from '../../../helpers'; import { API, showSuccess, showError } from '../../../helpers';
import { StatusContext } from '../../../context/Status'; import { StatusContext } from '../../../context/Status';
import { mergeAdminConfig } from '../../../hooks/common/useSidebar';
const { Text } = Typography; const { Text } = Typography;
@@ -39,36 +40,9 @@ export default function SettingsSidebarModulesAdmin(props) {
const [statusState, statusDispatch] = useContext(StatusContext); const [statusState, statusDispatch] = useContext(StatusContext);
// 左侧边栏模块管理状态(管理员全局控制) // 左侧边栏模块管理状态(管理员全局控制)
const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({ const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState(
chat: { mergeAdminConfig(null),
enabled: true, );
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
subscription: true,
setting: true,
},
});
// 处理区域级别开关变更 // 处理区域级别开关变更
function handleSectionChange(sectionKey) { function handleSectionChange(sectionKey) {
@@ -100,36 +74,7 @@ export default function SettingsSidebarModulesAdmin(props) {
// 重置为默认配置 // 重置为默认配置
function resetSidebarModules() { function resetSidebarModules() {
const defaultModules = { const defaultModules = mergeAdminConfig(null);
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
subscription: true,
setting: true,
},
};
setSidebarModulesAdmin(defaultModules); setSidebarModulesAdmin(defaultModules);
showSuccess(t('已重置为默认配置')); showSuccess(t('已重置为默认配置'));
} }
@@ -174,32 +119,9 @@ export default function SettingsSidebarModulesAdmin(props) {
if (props.options && props.options.SidebarModulesAdmin) { if (props.options && props.options.SidebarModulesAdmin) {
try { try {
const modules = JSON.parse(props.options.SidebarModulesAdmin); const modules = JSON.parse(props.options.SidebarModulesAdmin);
setSidebarModulesAdmin(modules); setSidebarModulesAdmin(mergeAdminConfig(modules));
} catch (error) { } catch (error) {
// 使用默认配置 setSidebarModulesAdmin(mergeAdminConfig(null));
const defaultModules = {
chat: { enabled: true, playground: true, chat: true },
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: { enabled: true, topup: true, personal: true },
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
subscription: true,
setting: true,
},
};
setSidebarModulesAdmin(defaultModules);
} }
} }
}, [props.options]); }, [props.options]);
@@ -248,6 +170,18 @@ export default function SettingsSidebarModulesAdmin(props) {
}, },
], ],
}, },
{
key: 'other',
title: t('其他功能区域'),
description: t('图片与扩展功能'),
modules: [
{
key: 'image_studio',
title: t('图片制作'),
description: t('生成图片和编辑图片'),
},
],
},
{ {
key: 'admin', key: 'admin',
title: t('管理员区域'), title: t('管理员区域'),