refactor: remove mt theme, clean up New API branding from classic frontend
Docker Build / Build and Push Docker Image (push) Successful in 3m54s
@@ -20,17 +20,6 @@ COPY ./web/classic ./classic
|
|||||||
COPY ./VERSION /build/VERSION
|
COPY ./VERSION /build/VERSION
|
||||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||||
|
|
||||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-mt
|
|
||||||
|
|
||||||
WORKDIR /build/web
|
|
||||||
COPY web/package.json web/bun.lock ./
|
|
||||||
COPY web/default/package.json ./default/package.json
|
|
||||||
COPY web/classic/package.json ./classic/package.json
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
COPY ./web/mt ./classic
|
|
||||||
COPY ./VERSION /build/VERSION
|
|
||||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/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
|
||||||
|
|
||||||
@@ -47,7 +36,6 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /build/web/default/dist ./web/default/dist
|
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||||
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||||
COPY --from=builder-mt /build/web/classic/dist ./web/mt/dist
|
|
||||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ func GetTheme() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetTheme updates the frontend theme atomically.
|
// SetTheme updates the frontend theme atomically.
|
||||||
// Only "default", "classic", and "mt" are accepted; other values are silently ignored.
|
// Only "default" and "classic" are accepted; other values are silently ignored.
|
||||||
func SetTheme(t string) {
|
func SetTheme(t string) {
|
||||||
if t == "default" || t == "classic" || t == "mt" {
|
if t == "default" || t == "classic" {
|
||||||
themeValue.Store(t)
|
themeValue.Store(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,15 +48,12 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
|||||||
type themeAwareFileSystem struct {
|
type themeAwareFileSystem struct {
|
||||||
defaultFS static.ServeFileSystem
|
defaultFS static.ServeFileSystem
|
||||||
classicFS static.ServeFileSystem
|
classicFS static.ServeFileSystem
|
||||||
mtFS static.ServeFileSystem
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
|
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
|
||||||
switch GetTheme() {
|
switch GetTheme() {
|
||||||
case "classic":
|
case "classic":
|
||||||
return t.classicFS.Exists(prefix, path)
|
return t.classicFS.Exists(prefix, path)
|
||||||
case "mt":
|
|
||||||
return t.mtFS.Exists(prefix, path)
|
|
||||||
default:
|
default:
|
||||||
return t.defaultFS.Exists(prefix, path)
|
return t.defaultFS.Exists(prefix, path)
|
||||||
}
|
}
|
||||||
@@ -66,13 +63,11 @@ func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
|
|||||||
switch GetTheme() {
|
switch GetTheme() {
|
||||||
case "classic":
|
case "classic":
|
||||||
return t.classicFS.Open(name)
|
return t.classicFS.Open(name)
|
||||||
case "mt":
|
|
||||||
return t.mtFS.Open(name)
|
|
||||||
default:
|
default:
|
||||||
return t.defaultFS.Open(name)
|
return t.defaultFS.Open(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewThemeAwareFS(defaultFS, classicFS, mtFS static.ServeFileSystem) static.ServeFileSystem {
|
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
|
||||||
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS, mtFS: mtFS}
|
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,10 +204,10 @@ func UpdateOption(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "theme.frontend":
|
case "theme.frontend":
|
||||||
if option.Value != "default" && option.Value != "classic" && option.Value != "mt" {
|
if option.Value != "default" && option.Value != "classic" {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)、mt(MT 前端)",
|
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,6 @@ var classicBuildFS embed.FS
|
|||||||
//go:embed web/classic/dist/index.html
|
//go:embed web/classic/dist/index.html
|
||||||
var classicIndexPage []byte
|
var classicIndexPage []byte
|
||||||
|
|
||||||
//go:embed web/mt/dist
|
|
||||||
var mtBuildFS embed.FS
|
|
||||||
|
|
||||||
//go:embed web/mt/dist/index.html
|
|
||||||
var mtIndexPage []byte
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@@ -201,8 +195,6 @@ func main() {
|
|||||||
DefaultIndexPage: indexPage,
|
DefaultIndexPage: indexPage,
|
||||||
ClassicBuildFS: classicBuildFS,
|
ClassicBuildFS: classicBuildFS,
|
||||||
ClassicIndexPage: classicIndexPage,
|
ClassicIndexPage: classicIndexPage,
|
||||||
MtBuildFS: mtBuildFS,
|
|
||||||
MtIndexPage: mtIndexPage,
|
|
||||||
})
|
})
|
||||||
var port = os.Getenv("PORT")
|
var port = os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
|
|||||||
@@ -19,15 +19,12 @@ type ThemeAssets struct {
|
|||||||
DefaultIndexPage []byte
|
DefaultIndexPage []byte
|
||||||
ClassicBuildFS embed.FS
|
ClassicBuildFS embed.FS
|
||||||
ClassicIndexPage []byte
|
ClassicIndexPage []byte
|
||||||
MtBuildFS embed.FS
|
|
||||||
MtIndexPage []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
||||||
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
|
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
|
||||||
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
|
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
|
||||||
mtFS := common.EmbedFolder(assets.MtBuildFS, "web/mt/dist")
|
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
|
||||||
themeFS := common.NewThemeAwareFS(defaultFS, classicFS, mtFS)
|
|
||||||
|
|
||||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
router.Use(middleware.GlobalWebRateLimit())
|
router.Use(middleware.GlobalWebRateLimit())
|
||||||
@@ -43,8 +40,6 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
|||||||
switch common.GetTheme() {
|
switch common.GetTheme() {
|
||||||
case "classic":
|
case "classic":
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
||||||
case "mt":
|
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.MtIndexPage)
|
|
||||||
default:
|
default:
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ const FooterBar = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 gap-8 w-full'>
|
||||||
<div className='text-left'>
|
<div className='text-left'>
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||||
{t('关于我们')}
|
{t('关于我们')}
|
||||||
</p>
|
</p>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<a
|
<a
|
||||||
href='https://docs.newapi.pro/wiki/project-introduction/'
|
href='https://modelstoken.com'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='!text-semi-color-text-1'
|
className='!text-semi-color-text-1'
|
||||||
@@ -71,21 +71,13 @@ const FooterBar = () => {
|
|||||||
{t('关于项目')}
|
{t('关于项目')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href='https://docs.newapi.pro/support/community-interaction/'
|
href='https://modelstoken.com'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='!text-semi-color-text-1'
|
className='!text-semi-color-text-1'
|
||||||
>
|
>
|
||||||
{t('联系我们')}
|
{t('联系我们')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/wiki/features-introduction/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('功能特性')}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,7 +87,7 @@ const FooterBar = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<a
|
<a
|
||||||
href='https://docs.newapi.pro/getting-started/'
|
href='https://modelstoken.com'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='!text-semi-color-text-1'
|
className='!text-semi-color-text-1'
|
||||||
@@ -103,15 +95,7 @@ const FooterBar = () => {
|
|||||||
{t('快速开始')}
|
{t('快速开始')}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href='https://docs.newapi.pro/installation/'
|
href='https://modelstoken.com'
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('安装指南')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/api/'
|
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='!text-semi-color-text-1'
|
className='!text-semi-color-text-1'
|
||||||
@@ -120,70 +104,6 @@ const FooterBar = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('相关项目')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://github.com/songquanpeng/one-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
One API
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/novicezk/midjourney-proxy'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
Midjourney-Proxy
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/Calcium-Ion/new-api-key-tool'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
new-api-key-tool
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('友情链接')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://github.com/Calcium-Ion/new-api-horizon'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
new-api-horizon
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/coaidev/coai'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
CoAI
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://www.gpt-load.com/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
GPT-Load
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -194,20 +114,6 @@ const FooterBar = () => {
|
|||||||
© {currentYear} {systemName}. {t('版权所有')}
|
© {currentYear} {systemName}. {t('版权所有')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='text-sm'>
|
|
||||||
<span className='!text-semi-color-text-1'>
|
|
||||||
{t('设计与开发由')}{' '}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href='https://github.com/QuantumNous/new-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary font-medium'
|
|
||||||
>
|
|
||||||
New API
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
),
|
),
|
||||||
@@ -227,19 +133,6 @@ const FooterBar = () => {
|
|||||||
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
|
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
|
||||||
dangerouslySetInnerHTML={{ __html: footer }}
|
dangerouslySetInnerHTML={{ __html: footer }}
|
||||||
></div>
|
></div>
|
||||||
<div className='text-sm flex-shrink-0'>
|
|
||||||
<span className='!text-semi-color-text-1'>
|
|
||||||
{t('设计与开发由')}{' '}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href='https://github.com/QuantumNous/new-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary font-medium'
|
|
||||||
>
|
|
||||||
New API
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function isRoot() {
|
|||||||
|
|
||||||
export function getSystemName() {
|
export function getSystemName() {
|
||||||
let system_name = localStorage.getItem('system_name');
|
let system_name = localStorage.getItem('system_name');
|
||||||
if (!system_name) return 'New API';
|
if (!system_name) return 'ModelsToken';
|
||||||
return system_name;
|
return system_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import en_GB from '@douyinfe/semi-ui/lib/es/locale/source/en_GB';
|
|||||||
// Welcome message (Do not remove this without permission from the original developer)
|
// Welcome message (Do not remove this without permission from the original developer)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
console.log(
|
console.log(
|
||||||
'%cWE ❤ NEWAPI%c Github: https://github.com/QuantumNous/new-api',
|
'%cWE ❤ ModelsToken%c https://modelstoken.com',
|
||||||
'color: #10b981; font-weight: bold; font-size: 24px;',
|
'color: #10b981; font-weight: bold; font-size: 24px;',
|
||||||
'color: inherit; font-size: 14px;',
|
'color: inherit; font-size: 14px;',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,63 +62,11 @@ const About = () => {
|
|||||||
const customDescription = (
|
const customDescription = (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
||||||
{t('New API项目仓库地址:')}
|
|
||||||
<a
|
|
||||||
href='https://github.com/QuantumNous/new-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
https://github.com/QuantumNous/new-api
|
|
||||||
</a>
|
|
||||||
<p>
|
<p>
|
||||||
<a
|
ModelsToken © {currentYear} modelstoken.com
|
||||||
href='https://github.com/QuantumNous/new-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
NewAPI
|
|
||||||
</a>{' '}
|
|
||||||
{t('© {{currentYear}}', { currentYear })}{' '}
|
|
||||||
<a
|
|
||||||
href='https://github.com/QuantumNous'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
QuantumNous
|
|
||||||
</a>{' '}
|
|
||||||
{t('| 基于')}{' '}
|
|
||||||
<a
|
|
||||||
href='https://github.com/songquanpeng/one-api/releases/tag/v0.5.4'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
One API v0.5.4
|
|
||||||
</a>{' '}
|
|
||||||
© 2023{' '}
|
|
||||||
<a
|
|
||||||
href='https://github.com/songquanpeng'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
JustSong
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t('本项目根据')}
|
{t('本项目根据')}
|
||||||
<a
|
|
||||||
href='https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-primary'
|
|
||||||
>
|
|
||||||
{t('MIT许可证')}
|
|
||||||
</a>
|
|
||||||
{t('授权,需在遵守')}
|
|
||||||
<a
|
<a
|
||||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
@@ -127,7 +75,7 @@ const About = () => {
|
|||||||
>
|
>
|
||||||
{t('AGPL v3.0协议')}
|
{t('AGPL v3.0协议')}
|
||||||
</a>
|
</a>
|
||||||
{t('的前提下使用。')}
|
{t('授权使用。')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2021: true, node: true },
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: { jsx: true },
|
|
||||||
},
|
|
||||||
plugins: ['header', 'react-hooks'],
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ['**/*.{js,jsx}'],
|
|
||||||
rules: {
|
|
||||||
'header/header': [
|
|
||||||
2,
|
|
||||||
'block',
|
|
||||||
[
|
|
||||||
'',
|
|
||||||
'Copyright (C) 2025 QuantumNous',
|
|
||||||
'',
|
|
||||||
'This program is free software: you can redistribute it and/or modify',
|
|
||||||
'it under the terms of the GNU Affero General Public License as',
|
|
||||||
'published by the Free Software Foundation, either version 3 of the',
|
|
||||||
'License, or (at your option) any later version.',
|
|
||||||
'',
|
|
||||||
'This program is distributed in the hope that it will be useful,',
|
|
||||||
'but WITHOUT ANY WARRANTY; without even the implied warranty of',
|
|
||||||
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
|
|
||||||
'GNU Affero General Public License for more details.',
|
|
||||||
'',
|
|
||||||
'You should have received a copy of the GNU Affero General Public License',
|
|
||||||
'along with this program. If not, see <https://www.gnu.org/licenses/>.',
|
|
||||||
'',
|
|
||||||
'For commercial licensing, please contact support@quantumnous.com',
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.idea
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
module.exports = require('@so1ve/prettier-config');
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<!DOCTYPE html><html><head><meta charset=utf-8><title>Classic Theme</title></head><body><div id=root></div></body></html>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 QuantumNous
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineConfig } from 'i18next-cli';
|
|
||||||
|
|
||||||
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
|
||||||
export default defineConfig({
|
|
||||||
locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'],
|
|
||||||
extract: {
|
|
||||||
input: ['src/**/*.{js,jsx,ts,tsx}'],
|
|
||||||
ignore: ['src/i18n/**/*'],
|
|
||||||
output: 'src/i18n/locales/{{language}}.json',
|
|
||||||
ignoredAttributes: [
|
|
||||||
'accept',
|
|
||||||
'align',
|
|
||||||
'aria-label',
|
|
||||||
'autoComplete',
|
|
||||||
'className',
|
|
||||||
'clipRule',
|
|
||||||
'color',
|
|
||||||
'crossOrigin',
|
|
||||||
'data-index',
|
|
||||||
'data-name',
|
|
||||||
'data-testid',
|
|
||||||
'data-type',
|
|
||||||
'defaultActiveKey',
|
|
||||||
'direction',
|
|
||||||
'editorType',
|
|
||||||
'field',
|
|
||||||
'fill',
|
|
||||||
'fillRule',
|
|
||||||
'height',
|
|
||||||
'hoverStyle',
|
|
||||||
'htmlType',
|
|
||||||
'id',
|
|
||||||
'itemKey',
|
|
||||||
'key',
|
|
||||||
'keyPrefix',
|
|
||||||
'layout',
|
|
||||||
'margin',
|
|
||||||
'maxHeight',
|
|
||||||
'mode',
|
|
||||||
'name',
|
|
||||||
'overflow',
|
|
||||||
'placement',
|
|
||||||
'position',
|
|
||||||
'rel',
|
|
||||||
'role',
|
|
||||||
'rowKey',
|
|
||||||
'searchPosition',
|
|
||||||
'selectedStyle',
|
|
||||||
'shape',
|
|
||||||
'size',
|
|
||||||
'style',
|
|
||||||
'theme',
|
|
||||||
'trigger',
|
|
||||||
'uploadTrigger',
|
|
||||||
'validateStatus',
|
|
||||||
'value',
|
|
||||||
'viewBox',
|
|
||||||
'width',
|
|
||||||
],
|
|
||||||
sort: true,
|
|
||||||
disablePlurals: false,
|
|
||||||
removeUnusedKeys: false,
|
|
||||||
nsSeparator: false,
|
|
||||||
keySeparator: false,
|
|
||||||
mergeNamespaces: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="/logo.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#ffffff" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
lang="zh"
|
|
||||||
content="统一的 AI 模型聚合与分发网关,支持将各类大语言模型跨格式转换为 OpenAI、Claude、Gemini 兼容接口,为个人与企业提供集中式模型管理与网关服务。"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
lang="en"
|
|
||||||
content="A unified AI model hub for aggregation & distribution. It supports cross-converting various LLMs into OpenAI-compatible, Claude-compatible, or Gemini-compatible formats. A centralized gateway for personal and enterprise model management."
|
|
||||||
/>
|
|
||||||
<meta name="generator" content="new-api" />
|
|
||||||
<title>ModelsToken</title>
|
|
||||||
<!--umami-->
|
|
||||||
<!--Google Analytics-->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "react-template",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@douyinfe/semi-illustrations": "^2.69.1",
|
|
||||||
"@douyinfe/semi-icons": "^2.63.1",
|
|
||||||
"@douyinfe/semi-ui": "^2.69.1",
|
|
||||||
"@lobehub/icons": "catalog:",
|
|
||||||
"@visactor/react-vchart": "~1.8.8",
|
|
||||||
"@visactor/vchart": "~1.8.8",
|
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
|
||||||
"axios": "catalog:",
|
|
||||||
"clsx": "catalog:",
|
|
||||||
"dayjs": "catalog:",
|
|
||||||
"history": "^5.3.0",
|
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"i18next": "^23.16.8",
|
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
|
||||||
"katex": "^0.16.22",
|
|
||||||
"lucide-react": "^0.511.0",
|
|
||||||
"marked": "^4.1.1",
|
|
||||||
"mermaid": "^11.6.0",
|
|
||||||
"qrcode.react": "catalog:",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"react-dropzone": "^14.2.3",
|
|
||||||
"react-fireworks": "^1.0.4",
|
|
||||||
"react-i18next": "^13.0.0",
|
|
||||||
"react-icons": "catalog:",
|
|
||||||
"react-markdown": "catalog:",
|
|
||||||
"react-router-dom": "^6.3.0",
|
|
||||||
"react-telegram-login": "^1.1.2",
|
|
||||||
"react-toastify": "^9.0.8",
|
|
||||||
"react-turnstile": "^1.0.5",
|
|
||||||
"rehype-highlight": "^7.0.2",
|
|
||||||
"rehype-katex": "^7.0.1",
|
|
||||||
"remark-breaks": "^4.0.0",
|
|
||||||
"remark-gfm": "catalog:",
|
|
||||||
"remark-math": "^6.0.0",
|
|
||||||
"sse.js": "catalog:",
|
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"use-debounce": "^10.0.4"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "rsbuild dev",
|
|
||||||
"build": "rsbuild build",
|
|
||||||
"lint": "prettier . --check",
|
|
||||||
"lint:fix": "prettier . --write",
|
|
||||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
|
||||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
|
||||||
"preview": "rsbuild preview",
|
|
||||||
"i18n:extract": "bunx i18next-cli extract",
|
|
||||||
"i18n:status": "bunx i18next-cli status",
|
|
||||||
"i18n:sync": "bunx i18next-cli sync",
|
|
||||||
"i18n:lint": "bunx i18next-cli lint"
|
|
||||||
},
|
|
||||||
"eslintConfig": {
|
|
||||||
"extends": [
|
|
||||||
"react-app",
|
|
||||||
"react-app/jest"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@rsbuild/core": "^2.0.7",
|
|
||||||
"@rsbuild/plugin-react": "^2.0.0",
|
|
||||||
"@so1ve/prettier-config": "^3.1.0",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"eslint": "8.57.0",
|
|
||||||
"eslint-plugin-header": "^3.1.1",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"i18next-cli": "^1.10.3",
|
|
||||||
"postcss": "^8.5.3",
|
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"prettier": "catalog:",
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"typescript": "4.4.2"
|
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"singleQuote": true,
|
|
||||||
"jsxSingleQuote": true
|
|
||||||
},
|
|
||||||
"proxy": "http://localhost:3000"
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 QuantumNous
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 140 KiB |
@@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
|
|
||||||
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
|
|
||||||
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
|
|
||||||
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
|
|
||||||
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,106 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import { createRequire } from 'module'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { defineConfig, loadEnv } from '@rsbuild/core'
|
|
||||||
import { pluginReact } from '@rsbuild/plugin-react'
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
const semiUiDir = path.resolve(
|
|
||||||
path.dirname(require.resolve('@douyinfe/semi-ui')),
|
|
||||||
'../..',
|
|
||||||
)
|
|
||||||
|
|
||||||
export default defineConfig(({ envMode }) => {
|
|
||||||
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
|
|
||||||
const clientServerUrl =
|
|
||||||
process.env.VITE_REACT_APP_SERVER_URL ||
|
|
||||||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
|
|
||||||
''
|
|
||||||
const proxyServerUrl =
|
|
||||||
clientServerUrl ||
|
|
||||||
'http://localhost:3000'
|
|
||||||
const isProd = envMode === 'production'
|
|
||||||
const devProxy = Object.fromEntries(
|
|
||||||
(['/api', '/mj', '/pg'] as const).map((key) => [
|
|
||||||
key,
|
|
||||||
{ target: proxyServerUrl, changeOrigin: true },
|
|
||||||
]),
|
|
||||||
) as Record<string, { target: string; changeOrigin: boolean }>
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [pluginReact()],
|
|
||||||
source: {
|
|
||||||
entry: {
|
|
||||||
index: './src/index.jsx',
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
|
|
||||||
clientServerUrl,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
|
|
||||||
semiUiDir,
|
|
||||||
'dist/css/semi.css',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
html: {
|
|
||||||
template: './index.html',
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: '0.0.0.0',
|
|
||||||
strictPort: true,
|
|
||||||
proxy: devProxy,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
minify: isProd,
|
|
||||||
target: 'web',
|
|
||||||
distPath: {
|
|
||||||
root: 'dist',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
removeConsole: isProd ? ['log'] : false,
|
|
||||||
buildCache: {
|
|
||||||
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
rspack: {
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /src[\\/].*\.js$/,
|
|
||||||
type: 'javascript/auto',
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
loader: 'builtin:swc-loader',
|
|
||||||
options: {
|
|
||||||
jsc: {
|
|
||||||
parser: {
|
|
||||||
syntax: 'ecmascript',
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
transform: {
|
|
||||||
react: {
|
|
||||||
runtime: 'automatic',
|
|
||||||
development: !isProd,
|
|
||||||
refresh: !isProd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
|
||||||
import { Route, Routes, useLocation, useParams } from 'react-router-dom';
|
|
||||||
import Loading from './components/common/ui/Loading';
|
|
||||||
import User from './pages/User';
|
|
||||||
import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers';
|
|
||||||
import RegisterForm from './components/auth/RegisterForm';
|
|
||||||
import LoginForm from './components/auth/LoginForm';
|
|
||||||
import NotFound from './pages/NotFound';
|
|
||||||
import Forbidden from './pages/Forbidden';
|
|
||||||
import Setting from './pages/Setting';
|
|
||||||
import { StatusContext } from './context/Status';
|
|
||||||
|
|
||||||
import PasswordResetForm from './components/auth/PasswordResetForm';
|
|
||||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
|
|
||||||
import Channel from './pages/Channel';
|
|
||||||
import Token from './pages/Token';
|
|
||||||
import Redemption from './pages/Redemption';
|
|
||||||
import TopUp from './pages/TopUp';
|
|
||||||
import Log from './pages/Log';
|
|
||||||
import Chat from './pages/Chat';
|
|
||||||
import Chat2Link from './pages/Chat2Link';
|
|
||||||
import Midjourney from './pages/Midjourney';
|
|
||||||
import Pricing from './pages/Pricing';
|
|
||||||
import Task from './pages/Task';
|
|
||||||
import ModelPage from './pages/Model';
|
|
||||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
|
||||||
import Playground from './pages/Playground';
|
|
||||||
import Subscription from './pages/Subscription';
|
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
|
||||||
import Setup from './pages/Setup';
|
|
||||||
import SetupCheck from './components/layout/SetupCheck';
|
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
|
||||||
const About = lazy(() => import('./pages/About'));
|
|
||||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
|
||||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
|
||||||
|
|
||||||
function DynamicOAuth2Callback() {
|
|
||||||
const { provider } = useParams();
|
|
||||||
return <OAuth2Callback type={provider} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const location = useLocation();
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
|
|
||||||
// 获取模型广场权限配置
|
|
||||||
const pricingRequireAuth = useMemo(() => {
|
|
||||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
|
||||||
if (headerNavModulesConfig) {
|
|
||||||
try {
|
|
||||||
const modules = JSON.parse(headerNavModulesConfig);
|
|
||||||
|
|
||||||
// 处理向后兼容性:如果pricing是boolean,默认不需要登录
|
|
||||||
if (typeof modules.pricing === 'boolean') {
|
|
||||||
return false; // 默认不需要登录鉴权
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是对象格式,使用requireAuth配置
|
|
||||||
return modules.pricing?.requireAuth === true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析顶栏模块配置失败:', error);
|
|
||||||
return false; // 默认不需要登录
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false; // 默认不需要登录
|
|
||||||
}, [statusState?.status?.HeaderNavModules]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SetupCheck>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path='/'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/setup'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Setup />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path='/forbidden' element={<Forbidden />} />
|
|
||||||
<Route
|
|
||||||
path='/console/models'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<ModelPage />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/deployment'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<ModelDeploymentPage />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/subscription'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<Subscription />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/channel'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<Channel />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/token'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Token />
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/playground'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Playground />
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/redemption'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<Redemption />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/user'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<User />
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/user/reset'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<PasswordResetConfirm />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/login'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<AuthRedirect>
|
|
||||||
<LoginForm />
|
|
||||||
</AuthRedirect>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/register'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<AuthRedirect>
|
|
||||||
<RegisterForm />
|
|
||||||
</AuthRedirect>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/reset'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<PasswordResetForm />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/oauth/github'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<OAuth2Callback type='github'></OAuth2Callback>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/oauth/discord'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<OAuth2Callback type='discord'></OAuth2Callback>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/oauth/oidc'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>}>
|
|
||||||
<OAuth2Callback type='oidc'></OAuth2Callback>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/oauth/linuxdo'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<OAuth2Callback type='linuxdo'></OAuth2Callback>
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/oauth/:provider'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<DynamicOAuth2Callback />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/setting'
|
|
||||||
element={
|
|
||||||
<AdminRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Setting />
|
|
||||||
</Suspense>
|
|
||||||
</AdminRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/personal'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<PersonalSetting />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/topup'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<TopUp />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/log'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Log />
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Dashboard />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/midjourney'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Midjourney />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/task'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Task />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/pricing'
|
|
||||||
element={
|
|
||||||
pricingRequireAuth ? (
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense
|
|
||||||
fallback={<Loading></Loading>}
|
|
||||||
key={location.pathname}
|
|
||||||
>
|
|
||||||
<Pricing />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
) : (
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Pricing />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/about'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<About />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/user-agreement'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<UserAgreement />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/privacy-policy'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<PrivacyPolicy />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path='/console/chat/:id?'
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Chat />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* 方便使用chat2link直接跳转聊天... */}
|
|
||||||
<Route
|
|
||||||
path='/chat2link'
|
|
||||||
element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
|
||||||
<Chat2Link />
|
|
||||||
</Suspense>
|
|
||||||
</PrivateRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path='*' element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</SetupCheck>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,983 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { UserContext } from '../../context/User';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
getLogo,
|
|
||||||
showError,
|
|
||||||
showInfo,
|
|
||||||
showSuccess,
|
|
||||||
updateAPI,
|
|
||||||
getSystemName,
|
|
||||||
getOAuthProviderIcon,
|
|
||||||
setUserData,
|
|
||||||
onGitHubOAuthClicked,
|
|
||||||
onDiscordOAuthClicked,
|
|
||||||
onOIDCClicked,
|
|
||||||
onLinuxDOOAuthClicked,
|
|
||||||
onCustomOAuthClicked,
|
|
||||||
prepareCredentialRequestOptions,
|
|
||||||
buildAssertionResult,
|
|
||||||
isPasskeySupported,
|
|
||||||
} from '../../helpers';
|
|
||||||
import Turnstile from 'react-turnstile';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Divider,
|
|
||||||
Form,
|
|
||||||
Icon,
|
|
||||||
Modal,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
|
||||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
|
||||||
import TelegramLoginButton from 'react-telegram-login';
|
|
||||||
|
|
||||||
import {
|
|
||||||
IconGithubLogo,
|
|
||||||
IconMail,
|
|
||||||
IconLock,
|
|
||||||
IconKey,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
|
||||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
|
||||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
|
||||||
import TwoFAVerification from './TwoFAVerification';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { SiDiscord } from 'react-icons/si';
|
|
||||||
|
|
||||||
const LoginForm = () => {
|
|
||||||
let navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const githubButtonTextKeyByState = {
|
|
||||||
idle: '使用 GitHub 继续',
|
|
||||||
redirecting: '正在跳转 GitHub...',
|
|
||||||
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
|
|
||||||
};
|
|
||||||
const [inputs, setInputs] = useState({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
wechat_verification_code: '',
|
|
||||||
});
|
|
||||||
const { username, password } = inputs;
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
|
||||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
|
||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
|
||||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
|
||||||
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
|
||||||
const [wechatLoading, setWechatLoading] = useState(false);
|
|
||||||
const [githubLoading, setGithubLoading] = useState(false);
|
|
||||||
const [discordLoading, setDiscordLoading] = useState(false);
|
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
|
||||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
|
||||||
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
|
||||||
const [loginLoading, setLoginLoading] = useState(false);
|
|
||||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
|
||||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
|
|
||||||
useState(false);
|
|
||||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
|
||||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
|
||||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
|
||||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
|
||||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
|
||||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
|
||||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
|
||||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
|
||||||
const githubTimeoutRef = useRef(null);
|
|
||||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
|
||||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
|
||||||
|
|
||||||
const logo = getLogo();
|
|
||||||
const systemName = getSystemName();
|
|
||||||
|
|
||||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
|
||||||
if (affCode) {
|
|
||||||
localStorage.setItem('aff', affCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = useMemo(() => {
|
|
||||||
if (statusState?.status) return statusState.status;
|
|
||||||
const savedStatus = localStorage.getItem('status');
|
|
||||||
if (!savedStatus) return {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(savedStatus) || {};
|
|
||||||
} catch (err) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}, [statusState?.status]);
|
|
||||||
const hasCustomOAuthProviders =
|
|
||||||
(status.custom_oauth_providers || []).length > 0;
|
|
||||||
const hasOAuthLoginOptions = Boolean(
|
|
||||||
status.github_oauth ||
|
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth ||
|
|
||||||
hasCustomOAuthProviders,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status?.turnstile_check) {
|
|
||||||
setTurnstileEnabled(true);
|
|
||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 status 获取用户协议和隐私政策的启用状态
|
|
||||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
|
||||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isPasskeySupported()
|
|
||||||
.then(setPasskeySupported)
|
|
||||||
.catch(() => setPasskeySupported(false));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (githubTimeoutRef.current) {
|
|
||||||
clearTimeout(githubTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('expired')) {
|
|
||||||
showError(t('未登录或登录已过期,请重新登录'));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onWeChatLoginClicked = () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWechatLoading(true);
|
|
||||||
setShowWeChatLoginModal(true);
|
|
||||||
setWechatLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitWeChatVerificationCode = async () => {
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWechatCodeSubmitLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await API.get(
|
|
||||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
|
||||||
);
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
localStorage.setItem('user', JSON.stringify(data));
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
navigate('/');
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
setShowWeChatLoginModal(false);
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('登录失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setWechatCodeSubmitLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleChange(name, value) {
|
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitted(true);
|
|
||||||
setLoginLoading(true);
|
|
||||||
try {
|
|
||||||
if (username && password) {
|
|
||||||
const res = await API.post(
|
|
||||||
`/api/user/login?turnstile=${turnstileToken}`,
|
|
||||||
{
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
// 检查是否需要2FA验证
|
|
||||||
if (data && data.require_2fa) {
|
|
||||||
setShowTwoFA(true);
|
|
||||||
setLoginLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
if (username === 'root' && password === '123456') {
|
|
||||||
Modal.error({
|
|
||||||
title: '您正在使用默认密码!',
|
|
||||||
content: '请立刻修改默认密码!',
|
|
||||||
centered: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
navigate('/console');
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError('请输入用户名和密码!');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('登录失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setLoginLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加Telegram登录处理函数
|
|
||||||
const onTelegramLoginClicked = async (response) => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fields = [
|
|
||||||
'id',
|
|
||||||
'first_name',
|
|
||||||
'last_name',
|
|
||||||
'username',
|
|
||||||
'photo_url',
|
|
||||||
'auth_date',
|
|
||||||
'hash',
|
|
||||||
'lang',
|
|
||||||
];
|
|
||||||
const params = {};
|
|
||||||
fields.forEach((field) => {
|
|
||||||
if (response[field]) {
|
|
||||||
params[field] = response[field];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
localStorage.setItem('user', JSON.stringify(data));
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('登录失败,请重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的GitHub登录点击处理
|
|
||||||
const handleGitHubClick = () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (githubButtonDisabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGithubLoading(true);
|
|
||||||
setGithubButtonDisabled(true);
|
|
||||||
setGithubButtonState('redirecting');
|
|
||||||
if (githubTimeoutRef.current) {
|
|
||||||
clearTimeout(githubTimeoutRef.current);
|
|
||||||
}
|
|
||||||
githubTimeoutRef.current = setTimeout(() => {
|
|
||||||
setGithubLoading(false);
|
|
||||||
setGithubButtonState('timeout');
|
|
||||||
setGithubButtonDisabled(true);
|
|
||||||
}, 20000);
|
|
||||||
try {
|
|
||||||
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
||||||
setTimeout(() => setGithubLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的Discord登录点击处理
|
|
||||||
const handleDiscordClick = () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDiscordLoading(true);
|
|
||||||
try {
|
|
||||||
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
||||||
setTimeout(() => setDiscordLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的OIDC登录点击处理
|
|
||||||
const handleOIDCClick = () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOidcLoading(true);
|
|
||||||
try {
|
|
||||||
onOIDCClicked(
|
|
||||||
status.oidc_authorization_endpoint,
|
|
||||||
status.oidc_client_id,
|
|
||||||
false,
|
|
||||||
{ shouldLogout: true },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
||||||
setTimeout(() => setOidcLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的LinuxDO登录点击处理
|
|
||||||
const handleLinuxDOClick = () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLinuxdoLoading(true);
|
|
||||||
try {
|
|
||||||
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
||||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的自定义OAuth登录点击处理
|
|
||||||
const handleCustomOAuthClick = (provider) => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
|
||||||
try {
|
|
||||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
||||||
setTimeout(() => {
|
|
||||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的邮箱登录选项点击处理
|
|
||||||
const handleEmailLoginClick = () => {
|
|
||||||
setEmailLoginLoading(true);
|
|
||||||
setShowEmailLogin(true);
|
|
||||||
setEmailLoginLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasskeyLogin = async () => {
|
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!passkeySupported) {
|
|
||||||
showInfo('当前环境无法使用 Passkey 登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.PublicKeyCredential) {
|
|
||||||
showInfo('当前浏览器不支持 Passkey');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasskeyLoading(true);
|
|
||||||
try {
|
|
||||||
const beginRes = await API.post('/api/user/passkey/login/begin');
|
|
||||||
const { success, message, data } = beginRes.data;
|
|
||||||
if (!success) {
|
|
||||||
showError(message || '无法发起 Passkey 登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKeyOptions = prepareCredentialRequestOptions(
|
|
||||||
data?.options || data?.publicKey || data,
|
|
||||||
);
|
|
||||||
const assertion = await navigator.credentials.get({
|
|
||||||
publicKey: publicKeyOptions,
|
|
||||||
});
|
|
||||||
const payload = buildAssertionResult(assertion);
|
|
||||||
if (!payload) {
|
|
||||||
showError('Passkey 验证失败,请重试');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishRes = await API.post(
|
|
||||||
'/api/user/passkey/login/finish',
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
const finish = finishRes.data;
|
|
||||||
if (finish.success) {
|
|
||||||
userDispatch({ type: 'login', payload: finish.data });
|
|
||||||
setUserData(finish.data);
|
|
||||||
updateAPI();
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
navigate('/console');
|
|
||||||
} else {
|
|
||||||
showError(finish.message || 'Passkey 登录失败,请重试');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') {
|
|
||||||
showInfo('已取消 Passkey 登录');
|
|
||||||
} else {
|
|
||||||
showError('Passkey 登录失败,请重试');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPasskeyLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的重置密码点击处理
|
|
||||||
const handleResetPasswordClick = () => {
|
|
||||||
setResetPasswordLoading(true);
|
|
||||||
navigate('/reset');
|
|
||||||
setResetPasswordLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 包装的其他登录选项点击处理
|
|
||||||
const handleOtherLoginOptionsClick = () => {
|
|
||||||
setOtherLoginOptionsLoading(true);
|
|
||||||
setShowEmailLogin(false);
|
|
||||||
setOtherLoginOptionsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2FA验证成功处理
|
|
||||||
const handle2FASuccess = (data) => {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
navigate('/console');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 返回登录页面
|
|
||||||
const handleBackToLogin = () => {
|
|
||||||
setShowTwoFA(false);
|
|
||||||
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOAuthOptions = () => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3} className='!text-gray-800'>
|
|
||||||
{systemName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('登 录')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{status.wechat_login && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
|
||||||
}
|
|
||||||
onClick={onWeChatLoginClicked}
|
|
||||||
loading={wechatLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.github_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<IconGithubLogo size='large' />}
|
|
||||||
onClick={handleGitHubClick}
|
|
||||||
loading={githubLoading}
|
|
||||||
disabled={githubButtonDisabled}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{githubButtonText}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.discord_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<SiDiscord
|
|
||||||
style={{
|
|
||||||
color: '#5865F2',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={handleDiscordClick}
|
|
||||||
loading={discordLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.oidc_enabled && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
|
||||||
onClick={handleOIDCClick}
|
|
||||||
loading={oidcLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.linuxdo_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<LinuxDoIcon
|
|
||||||
style={{
|
|
||||||
color: '#E95420',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={handleLinuxDOClick}
|
|
||||||
loading={linuxdoLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.custom_oauth_providers &&
|
|
||||||
status.custom_oauth_providers.map((provider) => (
|
|
||||||
<Button
|
|
||||||
key={provider.slug}
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
|
||||||
onClick={() => handleCustomOAuthClick(provider)}
|
|
||||||
loading={customOAuthLoading[provider.slug]}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>
|
|
||||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{status.telegram_oauth && (
|
|
||||||
<div className='flex justify-center my-2'>
|
|
||||||
<TelegramLoginButton
|
|
||||||
dataOnauth={onTelegramLoginClicked}
|
|
||||||
botName={status.telegram_bot_name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.passkey_login && passkeySupported && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<IconKey size='large' />}
|
|
||||||
onClick={handlePasskeyLogin}
|
|
||||||
loading={passkeyLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider margin='12px' align='center'>
|
|
||||||
{t('或')}
|
|
||||||
</Divider>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
|
||||||
icon={<IconMail size='large' />}
|
|
||||||
onClick={handleEmailLoginClick}
|
|
||||||
loading={emailLoginLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
|
||||||
<div className='mt-6'>
|
|
||||||
<Checkbox
|
|
||||||
checked={agreedToTerms}
|
|
||||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
||||||
>
|
|
||||||
<Text size='small' className='text-gray-600'>
|
|
||||||
{t('我已阅读并同意')}
|
|
||||||
{hasUserAgreement && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/user-agreement'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('用户协议')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
|
||||||
{hasPrivacyPolicy && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/privacy-policy'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('隐私政策')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!status.self_use_mode_enabled && (
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
{t('没有账户?')}{' '}
|
|
||||||
<Link
|
|
||||||
to='/register'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('注册')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEmailLoginForm = () => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3}>{systemName}</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('登 录')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
{status.passkey_login && passkeySupported && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
type='tertiary'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
|
||||||
icon={<IconKey size='large' />}
|
|
||||||
onClick={handlePasskeyLogin}
|
|
||||||
loading={passkeyLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Form className='space-y-3'>
|
|
||||||
<Form.Input
|
|
||||||
field='username'
|
|
||||||
label={t('用户名或邮箱')}
|
|
||||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
|
||||||
name='username'
|
|
||||||
onChange={(value) => handleChange('username', value)}
|
|
||||||
prefix={<IconMail />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Input
|
|
||||||
field='password'
|
|
||||||
label={t('密码')}
|
|
||||||
placeholder={t('请输入您的密码')}
|
|
||||||
name='password'
|
|
||||||
mode='password'
|
|
||||||
onChange={(value) => handleChange('password', value)}
|
|
||||||
prefix={<IconLock />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
|
||||||
<div className='pt-4'>
|
|
||||||
<Checkbox
|
|
||||||
checked={agreedToTerms}
|
|
||||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
||||||
>
|
|
||||||
<Text size='small' className='text-gray-600'>
|
|
||||||
{t('我已阅读并同意')}
|
|
||||||
{hasUserAgreement && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/user-agreement'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('用户协议')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
|
||||||
{hasPrivacyPolicy && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/privacy-policy'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('隐私政策')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='space-y-2 pt-2'>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
type='primary'
|
|
||||||
htmlType='submit'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={loginLoading}
|
|
||||||
disabled={
|
|
||||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('继续')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
onClick={handleResetPasswordClick}
|
|
||||||
loading={resetPasswordLoading}
|
|
||||||
>
|
|
||||||
{t('忘记密码?')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{hasOAuthLoginOptions && (
|
|
||||||
<>
|
|
||||||
<Divider margin='12px' align='center'>
|
|
||||||
{t('或')}
|
|
||||||
</Divider>
|
|
||||||
|
|
||||||
<div className='mt-4 text-center'>
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
type='tertiary'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
onClick={handleOtherLoginOptionsClick}
|
|
||||||
loading={otherLoginOptionsLoading}
|
|
||||||
>
|
|
||||||
{t('其他登录选项')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!status.self_use_mode_enabled && (
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
{t('没有账户?')}{' '}
|
|
||||||
<Link
|
|
||||||
to='/register'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('注册')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 微信登录模态框
|
|
||||||
const renderWeChatLoginModal = () => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={t('微信扫码登录')}
|
|
||||||
visible={showWeChatLoginModal}
|
|
||||||
maskClosable={true}
|
|
||||||
onOk={onSubmitWeChatVerificationCode}
|
|
||||||
onCancel={() => setShowWeChatLoginModal(false)}
|
|
||||||
okText={t('登录')}
|
|
||||||
centered={true}
|
|
||||||
okButtonProps={{
|
|
||||||
loading: wechatCodeSubmitLoading,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-center mb-4'>
|
|
||||||
<p>
|
|
||||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form>
|
|
||||||
<Form.Input
|
|
||||||
field='wechat_verification_code'
|
|
||||||
placeholder={t('验证码')}
|
|
||||||
label={t('验证码')}
|
|
||||||
value={inputs.wechat_verification_code}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleChange('wechat_verification_code', value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2FA验证弹窗
|
|
||||||
const render2FAModal = () => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
|
|
||||||
<svg
|
|
||||||
className='w-4 h-4 text-green-600 dark:text-green-400'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
两步验证
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
visible={showTwoFA}
|
|
||||||
onCancel={handleBackToLogin}
|
|
||||||
footer={null}
|
|
||||||
width={450}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<TwoFAVerification
|
|
||||||
onSuccess={handle2FASuccess}
|
|
||||||
onBack={handleBackToLogin}
|
|
||||||
isModal={true}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
{/* 背景模糊晕染球 */}
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-indigo'
|
|
||||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-teal'
|
|
||||||
style={{ top: '50%', left: '-120px' }}
|
|
||||||
/>
|
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
|
||||||
{showEmailLogin ||
|
|
||||||
!hasOAuthLoginOptions
|
|
||||||
? renderEmailLoginForm()
|
|
||||||
: renderOAuthOptions()}
|
|
||||||
{renderWeChatLoginModal()}
|
|
||||||
{render2FAModal()}
|
|
||||||
|
|
||||||
{turnstileEnabled && (
|
|
||||||
<div className='flex justify-center mt-6'>
|
|
||||||
<Turnstile
|
|
||||||
sitekey={turnstileSiteKey}
|
|
||||||
onVerify={(token) => {
|
|
||||||
setTurnstileToken(token);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginForm;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext, useEffect, useRef } from 'react';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
showError,
|
|
||||||
showSuccess,
|
|
||||||
updateAPI,
|
|
||||||
setUserData,
|
|
||||||
} from '../../helpers';
|
|
||||||
import { UserContext } from '../../context/User';
|
|
||||||
import Loading from '../common/ui/Loading';
|
|
||||||
|
|
||||||
const OAuth2Callback = (props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [, userDispatch] = useContext(UserContext);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// 防止 React 18 Strict Mode 下重复执行
|
|
||||||
const hasExecuted = useRef(false);
|
|
||||||
|
|
||||||
// 最大重试次数
|
|
||||||
const MAX_RETRIES = 3;
|
|
||||||
|
|
||||||
const sendCode = async (code, state, retry = 0) => {
|
|
||||||
try {
|
|
||||||
const { data: resData } = await API.get(
|
|
||||||
`/api/oauth/${props.type}?code=${code}&state=${state}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { success, message, data } = resData;
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
// 业务错误不重试,直接显示错误
|
|
||||||
showError(message || t('授权失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data?.action === 'bind') {
|
|
||||||
showSuccess(t('绑定成功!'));
|
|
||||||
navigate('/console/personal');
|
|
||||||
} else {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
localStorage.setItem('user', JSON.stringify(data));
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
showSuccess(t('登录成功!'));
|
|
||||||
navigate('/console/token');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 网络错误等可重试
|
|
||||||
if (retry < MAX_RETRIES) {
|
|
||||||
// 递增的退避等待
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
|
|
||||||
return sendCode(code, state, retry + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试次数耗尽,提示错误并返回设置页面
|
|
||||||
showError(error.message || t('授权失败'));
|
|
||||||
navigate('/console/personal');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 防止 React 18 Strict Mode 下重复执行
|
|
||||||
if (hasExecuted.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasExecuted.current = true;
|
|
||||||
|
|
||||||
const code = searchParams.get('code');
|
|
||||||
const state = searchParams.get('state');
|
|
||||||
|
|
||||||
// 参数缺失直接返回
|
|
||||||
if (!code) {
|
|
||||||
showError(t('未获取到授权码'));
|
|
||||||
navigate('/console/personal');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCode(code, state);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <Loading />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OAuth2Callback;
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
copy,
|
|
||||||
showError,
|
|
||||||
showNotice,
|
|
||||||
getLogo,
|
|
||||||
getSystemName,
|
|
||||||
} from '../../helpers';
|
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
|
||||||
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
|
|
||||||
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
|
||||||
|
|
||||||
const PasswordResetConfirm = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [inputs, setInputs] = useState({
|
|
||||||
email: '',
|
|
||||||
token: '',
|
|
||||||
});
|
|
||||||
const { email, token } = inputs;
|
|
||||||
const isValidResetLink = email && token;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [disableButton, setDisableButton] = useState(false);
|
|
||||||
const [countdown, setCountdown] = useState(30);
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [formApi, setFormApi] = useState(null);
|
|
||||||
|
|
||||||
const logo = getLogo();
|
|
||||||
const systemName = getSystemName();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let token = searchParams.get('token');
|
|
||||||
let email = searchParams.get('email');
|
|
||||||
setInputs({
|
|
||||||
token: token || '',
|
|
||||||
email: email || '',
|
|
||||||
});
|
|
||||||
if (formApi) {
|
|
||||||
formApi.setValues({
|
|
||||||
email: email || '',
|
|
||||||
newPassword: newPassword || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [searchParams, newPassword, formApi]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let countdownInterval = null;
|
|
||||||
if (disableButton && countdown > 0) {
|
|
||||||
countdownInterval = setInterval(() => {
|
|
||||||
setCountdown(countdown - 1);
|
|
||||||
}, 1000);
|
|
||||||
} else if (countdown === 0) {
|
|
||||||
setDisableButton(false);
|
|
||||||
setCountdown(30);
|
|
||||||
}
|
|
||||||
return () => clearInterval(countdownInterval);
|
|
||||||
}, [disableButton, countdown]);
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
if (!email || !token) {
|
|
||||||
showError(t('无效的重置链接,请重新发起密码重置请求'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDisableButton(true);
|
|
||||||
setLoading(true);
|
|
||||||
const res = await API.post(`/api/user/reset`, {
|
|
||||||
email,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
const { success, message } = res.data;
|
|
||||||
if (success) {
|
|
||||||
let password = res.data.data;
|
|
||||||
setNewPassword(password);
|
|
||||||
await copy(password);
|
|
||||||
showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`);
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
{/* 背景模糊晕染球 */}
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-indigo'
|
|
||||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-teal'
|
|
||||||
style={{ top: '50%', left: '-120px' }}
|
|
||||||
/>
|
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3} className='!text-gray-800'>
|
|
||||||
{systemName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('密码重置确认')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
{!isValidResetLink && (
|
|
||||||
<Banner
|
|
||||||
type='danger'
|
|
||||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
|
||||||
className='mb-4 !rounded-lg'
|
|
||||||
closeIcon={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Form
|
|
||||||
getFormApi={(api) => setFormApi(api)}
|
|
||||||
initValues={{
|
|
||||||
email: email || '',
|
|
||||||
newPassword: newPassword || '',
|
|
||||||
}}
|
|
||||||
className='space-y-4'
|
|
||||||
>
|
|
||||||
<Form.Input
|
|
||||||
field='email'
|
|
||||||
label={t('邮箱')}
|
|
||||||
name='email'
|
|
||||||
disabled={true}
|
|
||||||
prefix={<IconMail />}
|
|
||||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{newPassword && (
|
|
||||||
<Form.Input
|
|
||||||
field='newPassword'
|
|
||||||
label={t('新密码')}
|
|
||||||
name='newPassword'
|
|
||||||
disabled={true}
|
|
||||||
prefix={<IconLock />}
|
|
||||||
suffix={
|
|
||||||
<Button
|
|
||||||
icon={<IconCopy />}
|
|
||||||
type='tertiary'
|
|
||||||
theme='borderless'
|
|
||||||
onClick={async () => {
|
|
||||||
await copy(newPassword);
|
|
||||||
showNotice(
|
|
||||||
`${t('密码已复制到剪贴板:')} ${newPassword}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('复制')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='space-y-2 pt-2'>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
type='primary'
|
|
||||||
htmlType='submit'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={loading}
|
|
||||||
disabled={
|
|
||||||
disableButton || newPassword || !isValidResetLink
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{newPassword ? t('密码重置完成') : t('确认重置密码')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
<Link
|
|
||||||
to='/login'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('返回登录')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PasswordResetConfirm;
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
getLogo,
|
|
||||||
showError,
|
|
||||||
showInfo,
|
|
||||||
showSuccess,
|
|
||||||
getSystemName,
|
|
||||||
} from '../../helpers';
|
|
||||||
import Turnstile from 'react-turnstile';
|
|
||||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
|
||||||
import { IconMail } from '@douyinfe/semi-icons';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
|
||||||
|
|
||||||
const PasswordResetForm = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [inputs, setInputs] = useState({
|
|
||||||
email: '',
|
|
||||||
});
|
|
||||||
const { email } = inputs;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
|
||||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
|
||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
|
||||||
const [disableButton, setDisableButton] = useState(false);
|
|
||||||
const [countdown, setCountdown] = useState(30);
|
|
||||||
|
|
||||||
const logo = getLogo();
|
|
||||||
const systemName = getSystemName();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let status = localStorage.getItem('status');
|
|
||||||
if (status) {
|
|
||||||
status = JSON.parse(status);
|
|
||||||
if (status.turnstile_check) {
|
|
||||||
setTurnstileEnabled(true);
|
|
||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let countdownInterval = null;
|
|
||||||
if (disableButton && countdown > 0) {
|
|
||||||
countdownInterval = setInterval(() => {
|
|
||||||
setCountdown(countdown - 1);
|
|
||||||
}, 1000);
|
|
||||||
} else if (countdown === 0) {
|
|
||||||
setDisableButton(false);
|
|
||||||
setCountdown(30);
|
|
||||||
}
|
|
||||||
return () => clearInterval(countdownInterval);
|
|
||||||
}, [disableButton, countdown]);
|
|
||||||
|
|
||||||
function handleChange(value) {
|
|
||||||
setInputs((inputs) => ({ ...inputs, email: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
if (!email) {
|
|
||||||
showError(t('请输入邮箱地址'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDisableButton(true);
|
|
||||||
setLoading(true);
|
|
||||||
const res = await API.get(
|
|
||||||
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
|
|
||||||
);
|
|
||||||
const { success, message } = res.data;
|
|
||||||
if (success) {
|
|
||||||
showSuccess(t('重置邮件发送成功,请检查邮箱!'));
|
|
||||||
setInputs({ ...inputs, email: '' });
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
{/* 背景模糊晕染球 */}
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-indigo'
|
|
||||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-teal'
|
|
||||||
style={{ top: '50%', left: '-120px' }}
|
|
||||||
/>
|
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3} className='!text-gray-800'>
|
|
||||||
{systemName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('密码重置')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
<Form className='space-y-3'>
|
|
||||||
<Form.Input
|
|
||||||
field='email'
|
|
||||||
label={t('邮箱')}
|
|
||||||
placeholder={t('请输入您的邮箱地址')}
|
|
||||||
name='email'
|
|
||||||
value={email}
|
|
||||||
onChange={handleChange}
|
|
||||||
prefix={<IconMail />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='space-y-2 pt-2'>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
type='primary'
|
|
||||||
htmlType='submit'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={loading}
|
|
||||||
disabled={disableButton}
|
|
||||||
>
|
|
||||||
{disableButton
|
|
||||||
? `${t('重试')} (${countdown})`
|
|
||||||
: t('提交')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
{t('想起来了?')}{' '}
|
|
||||||
<Link
|
|
||||||
to='/login'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('登录')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{turnstileEnabled && (
|
|
||||||
<div className='flex justify-center mt-6'>
|
|
||||||
<Turnstile
|
|
||||||
sitekey={turnstileSiteKey}
|
|
||||||
onVerify={(token) => {
|
|
||||||
setTurnstileToken(token);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PasswordResetForm;
|
|
||||||
@@ -1,805 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
getLogo,
|
|
||||||
showError,
|
|
||||||
showInfo,
|
|
||||||
showSuccess,
|
|
||||||
updateAPI,
|
|
||||||
getSystemName,
|
|
||||||
getOAuthProviderIcon,
|
|
||||||
setUserData,
|
|
||||||
onDiscordOAuthClicked,
|
|
||||||
onCustomOAuthClicked,
|
|
||||||
} from '../../helpers';
|
|
||||||
import Turnstile from 'react-turnstile';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Divider,
|
|
||||||
Form,
|
|
||||||
Icon,
|
|
||||||
Modal,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
|
||||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
|
||||||
import {
|
|
||||||
IconGithubLogo,
|
|
||||||
IconMail,
|
|
||||||
IconUser,
|
|
||||||
IconLock,
|
|
||||||
IconKey,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import {
|
|
||||||
onGitHubOAuthClicked,
|
|
||||||
onLinuxDOOAuthClicked,
|
|
||||||
onOIDCClicked,
|
|
||||||
} from '../../helpers';
|
|
||||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
|
||||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
|
||||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
|
||||||
import TelegramLoginButton from 'react-telegram-login/src';
|
|
||||||
import { UserContext } from '../../context/User';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { SiDiscord } from 'react-icons/si';
|
|
||||||
|
|
||||||
const RegisterForm = () => {
|
|
||||||
let navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const githubButtonTextKeyByState = {
|
|
||||||
idle: '使用 GitHub 继续',
|
|
||||||
redirecting: '正在跳转 GitHub...',
|
|
||||||
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
|
|
||||||
};
|
|
||||||
const [inputs, setInputs] = useState({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
password2: '',
|
|
||||||
email: '',
|
|
||||||
verification_code: '',
|
|
||||||
wechat_verification_code: '',
|
|
||||||
});
|
|
||||||
const { username, password, password2 } = inputs;
|
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
|
||||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
|
||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
|
||||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
|
||||||
const [showEmailRegister, setShowEmailRegister] = useState(false);
|
|
||||||
const [wechatLoading, setWechatLoading] = useState(false);
|
|
||||||
const [githubLoading, setGithubLoading] = useState(false);
|
|
||||||
const [discordLoading, setDiscordLoading] = useState(false);
|
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
|
||||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
|
||||||
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
|
|
||||||
const [registerLoading, setRegisterLoading] = useState(false);
|
|
||||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
|
||||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
|
||||||
useState(false);
|
|
||||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
|
||||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
|
||||||
const [disableButton, setDisableButton] = useState(false);
|
|
||||||
const [countdown, setCountdown] = useState(30);
|
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
|
||||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
|
||||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
|
||||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
|
||||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
|
||||||
const githubTimeoutRef = useRef(null);
|
|
||||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
|
||||||
|
|
||||||
const logo = getLogo();
|
|
||||||
const systemName = getSystemName();
|
|
||||||
|
|
||||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
|
||||||
if (affCode) {
|
|
||||||
localStorage.setItem('aff', affCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = useMemo(() => {
|
|
||||||
if (statusState?.status) return statusState.status;
|
|
||||||
const savedStatus = localStorage.getItem('status');
|
|
||||||
if (!savedStatus) return {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(savedStatus) || {};
|
|
||||||
} catch (err) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}, [statusState?.status]);
|
|
||||||
const hasCustomOAuthProviders =
|
|
||||||
(status.custom_oauth_providers || []).length > 0;
|
|
||||||
const hasOAuthRegisterOptions = Boolean(
|
|
||||||
status.github_oauth ||
|
|
||||||
status.discord_oauth ||
|
|
||||||
status.oidc_enabled ||
|
|
||||||
status.wechat_login ||
|
|
||||||
status.linuxdo_oauth ||
|
|
||||||
status.telegram_oauth ||
|
|
||||||
hasCustomOAuthProviders,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowEmailVerification(!!status?.email_verification);
|
|
||||||
if (status?.turnstile_check) {
|
|
||||||
setTurnstileEnabled(true);
|
|
||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 status 获取用户协议和隐私政策的启用状态
|
|
||||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
|
||||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let countdownInterval = null;
|
|
||||||
if (disableButton && countdown > 0) {
|
|
||||||
countdownInterval = setInterval(() => {
|
|
||||||
setCountdown(countdown - 1);
|
|
||||||
}, 1000);
|
|
||||||
} else if (countdown === 0) {
|
|
||||||
setDisableButton(false);
|
|
||||||
setCountdown(30);
|
|
||||||
}
|
|
||||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
|
||||||
}, [disableButton, countdown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (githubTimeoutRef.current) {
|
|
||||||
clearTimeout(githubTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onWeChatLoginClicked = () => {
|
|
||||||
setWechatLoading(true);
|
|
||||||
setShowWeChatLoginModal(true);
|
|
||||||
setWechatLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitWeChatVerificationCode = async () => {
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWechatCodeSubmitLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await API.get(
|
|
||||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
|
||||||
);
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
localStorage.setItem('user', JSON.stringify(data));
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
navigate('/');
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
setShowWeChatLoginModal(false);
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('登录失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setWechatCodeSubmitLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleChange(name, value) {
|
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
if (password.length < 8) {
|
|
||||||
showInfo('密码长度不得小于 8 位!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password !== password2) {
|
|
||||||
showInfo('两次输入的密码不一致');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (username && password) {
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRegisterLoading(true);
|
|
||||||
try {
|
|
||||||
if (!affCode) {
|
|
||||||
affCode = localStorage.getItem('aff');
|
|
||||||
}
|
|
||||||
inputs.aff_code = affCode;
|
|
||||||
const res = await API.post(
|
|
||||||
`/api/user/register?turnstile=${turnstileToken}`,
|
|
||||||
inputs,
|
|
||||||
);
|
|
||||||
const { success, message } = res.data;
|
|
||||||
if (success) {
|
|
||||||
navigate('/login');
|
|
||||||
showSuccess('注册成功!');
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('注册失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setRegisterLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendVerificationCode = async () => {
|
|
||||||
if (inputs.email === '') return;
|
|
||||||
if (turnstileEnabled && turnstileToken === '') {
|
|
||||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setVerificationCodeLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await API.get(
|
|
||||||
`/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`,
|
|
||||||
);
|
|
||||||
const { success, message } = res.data;
|
|
||||||
if (success) {
|
|
||||||
showSuccess('验证码发送成功,请检查你的邮箱!');
|
|
||||||
setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('发送验证码失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setVerificationCodeLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGitHubClick = () => {
|
|
||||||
if (githubButtonDisabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGithubLoading(true);
|
|
||||||
setGithubButtonDisabled(true);
|
|
||||||
setGithubButtonState('redirecting');
|
|
||||||
if (githubTimeoutRef.current) {
|
|
||||||
clearTimeout(githubTimeoutRef.current);
|
|
||||||
}
|
|
||||||
githubTimeoutRef.current = setTimeout(() => {
|
|
||||||
setGithubLoading(false);
|
|
||||||
setGithubButtonState('timeout');
|
|
||||||
setGithubButtonDisabled(true);
|
|
||||||
}, 20000);
|
|
||||||
try {
|
|
||||||
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => setGithubLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDiscordClick = () => {
|
|
||||||
setDiscordLoading(true);
|
|
||||||
try {
|
|
||||||
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => setDiscordLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCClick = () => {
|
|
||||||
setOidcLoading(true);
|
|
||||||
try {
|
|
||||||
onOIDCClicked(
|
|
||||||
status.oidc_authorization_endpoint,
|
|
||||||
status.oidc_client_id,
|
|
||||||
false,
|
|
||||||
{ shouldLogout: true },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => setOidcLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLinuxDOClick = () => {
|
|
||||||
setLinuxdoLoading(true);
|
|
||||||
try {
|
|
||||||
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomOAuthClick = (provider) => {
|
|
||||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
|
||||||
try {
|
|
||||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailRegisterClick = () => {
|
|
||||||
setEmailRegisterLoading(true);
|
|
||||||
setShowEmailRegister(true);
|
|
||||||
setEmailRegisterLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOtherRegisterOptionsClick = () => {
|
|
||||||
setOtherRegisterOptionsLoading(true);
|
|
||||||
setShowEmailRegister(false);
|
|
||||||
setOtherRegisterOptionsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTelegramLoginClicked = async (response) => {
|
|
||||||
const fields = [
|
|
||||||
'id',
|
|
||||||
'first_name',
|
|
||||||
'last_name',
|
|
||||||
'username',
|
|
||||||
'photo_url',
|
|
||||||
'auth_date',
|
|
||||||
'hash',
|
|
||||||
'lang',
|
|
||||||
];
|
|
||||||
const params = {};
|
|
||||||
fields.forEach((field) => {
|
|
||||||
if (response[field]) {
|
|
||||||
params[field] = response[field];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
localStorage.setItem('user', JSON.stringify(data));
|
|
||||||
showSuccess('登录成功!');
|
|
||||||
setUserData(data);
|
|
||||||
updateAPI();
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('登录失败,请重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOAuthOptions = () => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3} className='!text-gray-800'>
|
|
||||||
{systemName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('注 册')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{status.wechat_login && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
|
||||||
}
|
|
||||||
onClick={onWeChatLoginClicked}
|
|
||||||
loading={wechatLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.github_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<IconGithubLogo size='large' />}
|
|
||||||
onClick={handleGitHubClick}
|
|
||||||
loading={githubLoading}
|
|
||||||
disabled={githubButtonDisabled}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{githubButtonText}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.discord_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<SiDiscord
|
|
||||||
style={{
|
|
||||||
color: '#5865F2',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={handleDiscordClick}
|
|
||||||
loading={discordLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.oidc_enabled && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
|
||||||
onClick={handleOIDCClick}
|
|
||||||
loading={oidcLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.linuxdo_oauth && (
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={
|
|
||||||
<LinuxDoIcon
|
|
||||||
style={{
|
|
||||||
color: '#E95420',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={handleLinuxDOClick}
|
|
||||||
loading={linuxdoLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.custom_oauth_providers &&
|
|
||||||
status.custom_oauth_providers.map((provider) => (
|
|
||||||
<Button
|
|
||||||
key={provider.slug}
|
|
||||||
theme='outline'
|
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
||||||
type='tertiary'
|
|
||||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
|
||||||
onClick={() => handleCustomOAuthClick(provider)}
|
|
||||||
loading={customOAuthLoading[provider.slug]}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>
|
|
||||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{status.telegram_oauth && (
|
|
||||||
<div className='flex justify-center my-2'>
|
|
||||||
<TelegramLoginButton
|
|
||||||
dataOnauth={onTelegramLoginClicked}
|
|
||||||
botName={status.telegram_bot_name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider margin='12px' align='center'>
|
|
||||||
{t('或')}
|
|
||||||
</Divider>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
|
||||||
icon={<IconMail size='large' />}
|
|
||||||
onClick={handleEmailRegisterClick}
|
|
||||||
loading={emailRegisterLoading}
|
|
||||||
>
|
|
||||||
<span className='ml-3'>{t('使用 用户名 注册')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
{t('已有账户?')}{' '}
|
|
||||||
<Link
|
|
||||||
to='/login'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('登录')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEmailRegisterForm = () => {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<div className='w-full max-w-md'>
|
|
||||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
||||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
||||||
<Title heading={3} className='!text-gray-800'>
|
|
||||||
{systemName}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
||||||
<div className='flex justify-center pt-6 pb-2'>
|
|
||||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
||||||
{t('注 册')}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<div className='px-2 py-8'>
|
|
||||||
<Form className='space-y-3'>
|
|
||||||
<Form.Input
|
|
||||||
field='username'
|
|
||||||
label={t('用户名')}
|
|
||||||
placeholder={t('请输入用户名')}
|
|
||||||
name='username'
|
|
||||||
onChange={(value) => handleChange('username', value)}
|
|
||||||
prefix={<IconUser />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Input
|
|
||||||
field='password'
|
|
||||||
label={t('密码')}
|
|
||||||
placeholder={t('输入密码,最短 8 位,最长 20 位')}
|
|
||||||
name='password'
|
|
||||||
mode='password'
|
|
||||||
onChange={(value) => handleChange('password', value)}
|
|
||||||
prefix={<IconLock />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Input
|
|
||||||
field='password2'
|
|
||||||
label={t('确认密码')}
|
|
||||||
placeholder={t('确认密码')}
|
|
||||||
name='password2'
|
|
||||||
mode='password'
|
|
||||||
onChange={(value) => handleChange('password2', value)}
|
|
||||||
prefix={<IconLock />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showEmailVerification && (
|
|
||||||
<>
|
|
||||||
<Form.Input
|
|
||||||
field='email'
|
|
||||||
label={t('邮箱')}
|
|
||||||
placeholder={t('输入邮箱地址')}
|
|
||||||
name='email'
|
|
||||||
type='email'
|
|
||||||
onChange={(value) => handleChange('email', value)}
|
|
||||||
prefix={<IconMail />}
|
|
||||||
suffix={
|
|
||||||
<Button
|
|
||||||
onClick={sendVerificationCode}
|
|
||||||
loading={verificationCodeLoading}
|
|
||||||
disabled={disableButton || verificationCodeLoading}
|
|
||||||
>
|
|
||||||
{disableButton
|
|
||||||
? `${t('重新发送')} (${countdown})`
|
|
||||||
: t('获取验证码')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Input
|
|
||||||
field='verification_code'
|
|
||||||
label={t('验证码')}
|
|
||||||
placeholder={t('输入验证码')}
|
|
||||||
name='verification_code'
|
|
||||||
onChange={(value) =>
|
|
||||||
handleChange('verification_code', value)
|
|
||||||
}
|
|
||||||
prefix={<IconKey />}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
|
||||||
<div className='pt-4'>
|
|
||||||
<Checkbox
|
|
||||||
checked={agreedToTerms}
|
|
||||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
||||||
>
|
|
||||||
<Text size='small' className='text-gray-600'>
|
|
||||||
{t('我已阅读并同意')}
|
|
||||||
{hasUserAgreement && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/user-agreement'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('用户协议')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
|
||||||
{hasPrivacyPolicy && (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href='/privacy-policy'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
||||||
>
|
|
||||||
{t('隐私政策')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='space-y-2 pt-2'>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
type='primary'
|
|
||||||
htmlType='submit'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={registerLoading}
|
|
||||||
disabled={
|
|
||||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('注册')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{hasOAuthRegisterOptions && (
|
|
||||||
<>
|
|
||||||
<Divider margin='12px' align='center'>
|
|
||||||
{t('或')}
|
|
||||||
</Divider>
|
|
||||||
|
|
||||||
<div className='mt-4 text-center'>
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
type='tertiary'
|
|
||||||
className='w-full !rounded-full'
|
|
||||||
onClick={handleOtherRegisterOptionsClick}
|
|
||||||
loading={otherRegisterOptionsLoading}
|
|
||||||
>
|
|
||||||
{t('其他注册选项')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='mt-6 text-center text-sm'>
|
|
||||||
<Text>
|
|
||||||
{t('已有账户?')}{' '}
|
|
||||||
<Link
|
|
||||||
to='/login'
|
|
||||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
|
||||||
>
|
|
||||||
{t('登录')}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderWeChatLoginModal = () => {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={t('微信扫码登录')}
|
|
||||||
visible={showWeChatLoginModal}
|
|
||||||
maskClosable={true}
|
|
||||||
onOk={onSubmitWeChatVerificationCode}
|
|
||||||
onCancel={() => setShowWeChatLoginModal(false)}
|
|
||||||
okText={t('登录')}
|
|
||||||
centered={true}
|
|
||||||
okButtonProps={{
|
|
||||||
loading: wechatCodeSubmitLoading,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex flex-col items-center'>
|
|
||||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-center mb-4'>
|
|
||||||
<p>
|
|
||||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form>
|
|
||||||
<Form.Input
|
|
||||||
field='wechat_verification_code'
|
|
||||||
placeholder={t('验证码')}
|
|
||||||
label={t('验证码')}
|
|
||||||
value={inputs.wechat_verification_code}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleChange('wechat_verification_code', value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
{/* 背景模糊晕染球 */}
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-indigo'
|
|
||||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='blur-ball blur-ball-teal'
|
|
||||||
style={{ top: '50%', left: '-120px' }}
|
|
||||||
/>
|
|
||||||
<div className='w-full max-w-sm mt-[60px]'>
|
|
||||||
{showEmailRegister ||
|
|
||||||
!hasOAuthRegisterOptions
|
|
||||||
? renderEmailRegisterForm()
|
|
||||||
: renderOAuthOptions()}
|
|
||||||
{renderWeChatLoginModal()}
|
|
||||||
|
|
||||||
{turnstileEnabled && (
|
|
||||||
<div className='flex justify-center mt-6'>
|
|
||||||
<Turnstile
|
|
||||||
sitekey={turnstileSiteKey}
|
|
||||||
onVerify={(token) => {
|
|
||||||
setTurnstileToken(token);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterForm;
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
import { API, showError, showSuccess } from '../../helpers';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!verificationCode) {
|
|
||||||
showError('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Validate code format
|
|
||||||
if (useBackupCode && verificationCode.length !== 8) {
|
|
||||||
showError('备用码必须是8位');
|
|
||||||
return;
|
|
||||||
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
|
|
||||||
showError('验证码必须是6位数字');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await API.post('/api/user/login/2fa', {
|
|
||||||
code: verificationCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.data.success) {
|
|
||||||
showSuccess('登录成功');
|
|
||||||
// 保存用户信息到本地存储
|
|
||||||
localStorage.setItem('user', JSON.stringify(res.data.data));
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess(res.data.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError(res.data.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('验证失败,请重试');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isModal) {
|
|
||||||
return (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<Paragraph className='text-gray-600 dark:text-gray-300'>
|
|
||||||
请输入认证器应用显示的验证码完成登录
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<Form.Input
|
|
||||||
field='code'
|
|
||||||
label={useBackupCode ? '备用码' : '验证码'}
|
|
||||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={setVerificationCode}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
size='large'
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
htmlType='submit'
|
|
||||||
type='primary'
|
|
||||||
loading={loading}
|
|
||||||
block
|
|
||||||
size='large'
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
验证并登录
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
onClick={() => {
|
|
||||||
setUseBackupCode(!useBackupCode);
|
|
||||||
setVerificationCode('');
|
|
||||||
}}
|
|
||||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
|
||||||
>
|
|
||||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onBack && (
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
onClick={onBack}
|
|
||||||
style={{ color: '#1890ff', padding: 0 }}
|
|
||||||
>
|
|
||||||
返回登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
|
|
||||||
<Text size='small' type='secondary'>
|
|
||||||
<strong>提示:</strong>
|
|
||||||
<br />
|
|
||||||
• 验证码每30秒更新一次
|
|
||||||
<br />
|
|
||||||
• 如果无法获取验证码,请使用备用码
|
|
||||||
<br />• 每个备用码只能使用一次
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '60vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card style={{ width: 400, padding: 24 }}>
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
|
||||||
<Title heading={3}>两步验证</Title>
|
|
||||||
<Paragraph type='secondary'>
|
|
||||||
请输入认证器应用显示的验证码完成登录
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<Form.Input
|
|
||||||
field='code'
|
|
||||||
label={useBackupCode ? '备用码' : '验证码'}
|
|
||||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={setVerificationCode}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
size='large'
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
htmlType='submit'
|
|
||||||
type='primary'
|
|
||||||
loading={loading}
|
|
||||||
block
|
|
||||||
size='large'
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
>
|
|
||||||
验证并登录
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
onClick={() => {
|
|
||||||
setUseBackupCode(!useBackupCode);
|
|
||||||
setVerificationCode('');
|
|
||||||
}}
|
|
||||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
|
||||||
>
|
|
||||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{onBack && (
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
onClick={onBack}
|
|
||||||
style={{ color: '#1890ff', padding: 0 }}
|
|
||||||
>
|
|
||||||
返回登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f6f8fa',
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size='small' type='secondary'>
|
|
||||||
<strong>提示:</strong>
|
|
||||||
<br />
|
|
||||||
• 验证码每30秒更新一次
|
|
||||||
<br />
|
|
||||||
• 如果无法获取验证码,请使用备用码
|
|
||||||
<br />• 每个备用码只能使用一次
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TwoFAVerification;
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { API, showError } from '../../../helpers';
|
|
||||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
|
||||||
const { Title } = Typography;
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
|
||||||
|
|
||||||
// Check whether content is a URL.
|
|
||||||
const isUrl = (content) => {
|
|
||||||
try {
|
|
||||||
new URL(content.trim());
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check whether content contains HTML.
|
|
||||||
const isHtmlContent = (content) => {
|
|
||||||
if (!content || typeof content !== 'string') return false;
|
|
||||||
|
|
||||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
|
||||||
return htmlTagRegex.test(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse HTML content and extract inline styles.
|
|
||||||
const sanitizeHtml = (html) => {
|
|
||||||
const tempDiv = document.createElement('div');
|
|
||||||
tempDiv.innerHTML = html;
|
|
||||||
|
|
||||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
|
||||||
.map((style) => style.innerHTML)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const bodyContent = tempDiv.querySelector('body');
|
|
||||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
|
||||||
|
|
||||||
return { content, styles };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用文档渲染组件
|
|
||||||
* @param {string} apiEndpoint - API 接口地址
|
|
||||||
* @param {string} title - 文档标题
|
|
||||||
* @param {string} cacheKey - 本地存储缓存键
|
|
||||||
* @param {string} emptyMessage - 空内容时的提示消息
|
|
||||||
*/
|
|
||||||
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadContent = async () => {
|
|
||||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
|
||||||
if (cachedContent) {
|
|
||||||
setContent(cachedContent);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await API.get(apiEndpoint);
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success && data) {
|
|
||||||
setContent(data);
|
|
||||||
localStorage.setItem(cacheKey, data);
|
|
||||||
} else {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(message || emptyMessage);
|
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(emptyMessage);
|
|
||||||
setContent('');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const htmlPayload = useMemo(() => {
|
|
||||||
if (!isHtmlContent(content)) {
|
|
||||||
return { content: '', styles: '' };
|
|
||||||
}
|
|
||||||
return sanitizeHtml(content);
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadContent();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 处理HTML样式注入
|
|
||||||
useEffect(() => {
|
|
||||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
|
||||||
const { styles } = htmlPayload;
|
|
||||||
|
|
||||||
if (styles) {
|
|
||||||
let styleEl = document.getElementById(styleId);
|
|
||||||
if (!styleEl) {
|
|
||||||
styleEl = document.createElement('style');
|
|
||||||
styleEl.id = styleId;
|
|
||||||
styleEl.type = 'text/css';
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
}
|
|
||||||
styleEl.innerHTML = styles;
|
|
||||||
} else {
|
|
||||||
const el = document.getElementById(styleId);
|
|
||||||
if (el) el.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const el = document.getElementById(styleId);
|
|
||||||
if (el) el.remove();
|
|
||||||
};
|
|
||||||
}, [cacheKey, htmlPayload]);
|
|
||||||
|
|
||||||
// 显示加载状态
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill flex justify-center items-center'>
|
|
||||||
<Spin size='large' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有内容,显示空状态
|
|
||||||
if (!content || content.trim() === '') {
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
|
|
||||||
<Empty
|
|
||||||
title={t('管理员未设置' + title + '内容')}
|
|
||||||
image={
|
|
||||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
className='p-8'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是 URL,显示链接卡片
|
|
||||||
if (isUrl(content)) {
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
|
|
||||||
<Card className='max-w-md w-full'>
|
|
||||||
<div className='text-center'>
|
|
||||||
<Title heading={4} className='mb-4'>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<p className='text-gray-600 mb-4'>
|
|
||||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={content.trim()}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
title={content.trim()}
|
|
||||||
aria-label={`${t('访问' + title)}: ${content.trim()}`}
|
|
||||||
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
|
|
||||||
>
|
|
||||||
{t('访问' + title)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是 HTML 内容,直接渲染
|
|
||||||
if (isHtmlContent(content)) {
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill bg-gray-50'>
|
|
||||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
|
||||||
<Title heading={2} className='text-center mb-8'>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<div
|
|
||||||
className='prose prose-lg max-w-none'
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他内容统一使用 Markdown 渲染器
|
|
||||||
return (
|
|
||||||
<div className='classic-page-fill bg-gray-50'>
|
|
||||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
|
||||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
|
||||||
<Title heading={2} className='text-center mb-8'>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<div className='prose prose-lg max-w-none'>
|
|
||||||
<MarkdownRenderer content={content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DocumentRenderer;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Empty, Button } from '@douyinfe/semi-ui';
|
|
||||||
import {
|
|
||||||
IllustrationFailure,
|
|
||||||
IllustrationFailureDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { withTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
|
||||||
console.error('[ErrorBoundary]', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
const { t } = this.props;
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col justify-center items-center h-screen p-8'>
|
|
||||||
<Empty
|
|
||||||
image={
|
|
||||||
<IllustrationFailure style={{ width: 250, height: 250 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
|
|
||||||
}
|
|
||||||
description={t('页面渲染出错,请刷新页面重试')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
{t('刷新页面')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withTranslation()(ErrorBoundary);
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
|
||||||
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
|
||||||
import { createApiCalls } from '../../../services/secureVerification';
|
|
||||||
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
|
||||||
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渠道密钥查看组件使用示例
|
|
||||||
* 展示如何使用通用安全验证系统
|
|
||||||
*/
|
|
||||||
const ChannelKeyViewExample = ({ channelId }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [keyData, setKeyData] = useState('');
|
|
||||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
|
||||||
|
|
||||||
// 使用通用安全验证 Hook
|
|
||||||
const {
|
|
||||||
isModalVisible,
|
|
||||||
verificationMethods,
|
|
||||||
verificationState,
|
|
||||||
startVerification,
|
|
||||||
executeVerification,
|
|
||||||
cancelVerification,
|
|
||||||
setVerificationCode,
|
|
||||||
switchVerificationMethod,
|
|
||||||
} = useSecureVerification({
|
|
||||||
onSuccess: (result) => {
|
|
||||||
// 验证成功后处理结果
|
|
||||||
if (result.success && result.data?.key) {
|
|
||||||
setKeyData(result.data.key);
|
|
||||||
setShowKeyModal(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
successMessage: t('密钥获取成功'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 开始查看密钥流程
|
|
||||||
const handleViewKey = async () => {
|
|
||||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
|
||||||
|
|
||||||
await startVerification(apiCall, {
|
|
||||||
title: t('查看渠道密钥'),
|
|
||||||
description: t('为了保护账户安全,请验证您的身份。'),
|
|
||||||
preferredMethod: 'passkey', // 可以指定首选验证方式
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 查看密钥按钮 */}
|
|
||||||
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
|
||||||
{t('查看密钥')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 安全验证模态框 */}
|
|
||||||
<SecureVerificationModal
|
|
||||||
visible={isModalVisible}
|
|
||||||
verificationMethods={verificationMethods}
|
|
||||||
verificationState={verificationState}
|
|
||||||
onVerify={executeVerification}
|
|
||||||
onCancel={cancelVerification}
|
|
||||||
onCodeChange={setVerificationCode}
|
|
||||||
onMethodSwitch={switchVerificationMethod}
|
|
||||||
title={verificationState.title}
|
|
||||||
description={verificationState.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 密钥显示模态框 */}
|
|
||||||
<Modal
|
|
||||||
title={t('渠道密钥信息')}
|
|
||||||
visible={showKeyModal}
|
|
||||||
onCancel={() => setShowKeyModal(false)}
|
|
||||||
footer={
|
|
||||||
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
|
||||||
{t('完成')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
width={700}
|
|
||||||
style={{ maxWidth: '90vw' }}
|
|
||||||
>
|
|
||||||
<ChannelKeyDisplay
|
|
||||||
keyData={keyData}
|
|
||||||
showSuccessIcon={true}
|
|
||||||
successText={t('密钥获取成功')}
|
|
||||||
showWarning={true}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChannelKeyViewExample;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Icon } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const LinuxDoIcon = (props) => {
|
|
||||||
function CustomIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className='icon'
|
|
||||||
viewBox='0 0 16 16'
|
|
||||||
version='1.1'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
width='1em'
|
|
||||||
height='1em'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<g id='linuxdo_icon' data-name='linuxdo_icon'>
|
|
||||||
<path
|
|
||||||
d='m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z'
|
|
||||||
fill='#EFEFEF'
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d='m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z'
|
|
||||||
fill='#FEB005'
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d='m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z'
|
|
||||||
fill='#1D1D1F'
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Icon svg={<CustomIcon />} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LinuxDoIcon;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Icon } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const OIDCIcon = (props) => {
|
|
||||||
function CustomIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
t='1723135116886'
|
|
||||||
className='icon'
|
|
||||||
viewBox='0 0 1024 1024'
|
|
||||||
version='1.1'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
p-id='10969'
|
|
||||||
width='20'
|
|
||||||
height='20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d='M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z'
|
|
||||||
p-id='10970'
|
|
||||||
fill='#2c2c2c'
|
|
||||||
stroke='#2c2c2c'
|
|
||||||
stroke-width='60'
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d='M197.7 512c0-78.3 31.6-98.8 87.2-98.8 56.2 0 87.2 20.5 87.2 98.8s-31 98.8-87.2 98.8c-55.7 0-87.2-20.5-87.2-98.8z m130.4 0c0-46.8-7.8-64.5-43.2-64.5-35.2 0-42.9 17.7-42.9 64.5 0 47.1 7.8 63.7 42.9 63.7 35.4 0 43.2-16.6 43.2-63.7zM409.7 415.9h42.1V608h-42.1V415.9zM653.9 512c0 74.2-37.1 96.1-93.6 96.1h-65.9V415.9h65.9c56.5 0 93.6 16.1 93.6 96.1z m-43.5 0c0-49.3-17.7-60.6-52.3-60.6h-21.6v120.7h21.6c35.4 0 52.3-13.3 52.3-60.1zM686.5 512c0-74.2 36.3-98.8 92.7-98.8 18.3 0 33.2 2.2 44.8 6.4v36.3c-11.9-4.2-26-6.6-42.1-6.6-34.6 0-49.8 15.5-49.8 62.6 0 50.1 15.2 62.6 49.3 62.6 15.8 0 30.2-2.2 44.8-7.5v36c-11.3 4.7-28.5 8-46.8 8-56.1-0.2-92.9-18.7-92.9-99z'
|
|
||||||
p-id='10971'
|
|
||||||
fill='#2c2c2c'
|
|
||||||
stroke='#2c2c2c'
|
|
||||||
stroke-width='20'
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Icon svg={<CustomIcon />} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OIDCIcon;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Icon } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const WeChatIcon = () => {
|
|
||||||
function CustomIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
t='1709714447384'
|
|
||||||
className='icon'
|
|
||||||
viewBox='0 0 1024 1024'
|
|
||||||
version='1.1'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
p-id='5091'
|
|
||||||
width='20'
|
|
||||||
height='20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'
|
|
||||||
p-id='5092'
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d='M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z'
|
|
||||||
p-id='5093'
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Icon svg={<CustomIcon />} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WeChatIcon;
|
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
import 'highlight.js/styles/github.css';
|
|
||||||
import './markdown.css';
|
|
||||||
import RemarkMath from 'remark-math';
|
|
||||||
import RemarkBreaks from 'remark-breaks';
|
|
||||||
import RehypeKatex from 'rehype-katex';
|
|
||||||
import RemarkGfm from 'remark-gfm';
|
|
||||||
import RehypeHighlight from 'rehype-highlight';
|
|
||||||
import { useRef, useState, useEffect, useMemo } from 'react';
|
|
||||||
import mermaid from 'mermaid';
|
|
||||||
import React from 'react';
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
|
||||||
import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
|
|
||||||
import { IconCopy } from '@douyinfe/semi-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
theme: 'default',
|
|
||||||
securityLevel: 'loose',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Mermaid(props) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const [hasError, setHasError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.code && ref.current) {
|
|
||||||
mermaid
|
|
||||||
.run({
|
|
||||||
nodes: [ref.current],
|
|
||||||
suppressErrors: true,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setHasError(true);
|
|
||||||
console.error('[Mermaid] ', e.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [props.code]);
|
|
||||||
|
|
||||||
function viewSvgInNewWindow() {
|
|
||||||
const svg = ref.current?.querySelector('svg');
|
|
||||||
if (!svg) return;
|
|
||||||
const text = new XMLSerializer().serializeToString(svg);
|
|
||||||
const blob = new Blob([text], { type: 'image/svg+xml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx('mermaid-container')}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '12px',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
backgroundColor: 'var(--semi-color-bg-1)',
|
|
||||||
margin: '12px 0',
|
|
||||||
}}
|
|
||||||
ref={ref}
|
|
||||||
onClick={() => viewSvgInNewWindow()}
|
|
||||||
>
|
|
||||||
{props.code}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SandboxedHtmlPreview({ code }) {
|
|
||||||
const iframeRef = useRef(null);
|
|
||||||
const [iframeHeight, setIframeHeight] = useState(150);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = iframeRef.current;
|
|
||||||
if (!iframe) return;
|
|
||||||
|
|
||||||
const handleLoad = () => {
|
|
||||||
try {
|
|
||||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
|
||||||
if (doc) {
|
|
||||||
const height =
|
|
||||||
doc.documentElement.scrollHeight || doc.body.scrollHeight;
|
|
||||||
setIframeHeight(Math.min(Math.max(height + 16, 60), 600));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// sandbox restrictions may prevent access, that's fine
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
iframe.addEventListener('load', handleLoad);
|
|
||||||
return () => iframe.removeEventListener('load', handleLoad);
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
sandbox='allow-same-origin'
|
|
||||||
srcDoc={code}
|
|
||||||
title='HTML Preview'
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: `${iframeHeight}px`,
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'auto',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PreCode(props) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const [mermaidCode, setMermaidCode] = useState('');
|
|
||||||
const [htmlCode, setHtmlCode] = useState('');
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const renderArtifacts = useDebouncedCallback(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const mermaidDom = ref.current.querySelector('code.language-mermaid');
|
|
||||||
if (mermaidDom) {
|
|
||||||
setMermaidCode(mermaidDom.innerText);
|
|
||||||
}
|
|
||||||
const htmlDom = ref.current.querySelector('code.language-html');
|
|
||||||
const refText = ref.current.querySelector('code')?.innerText;
|
|
||||||
if (htmlDom) {
|
|
||||||
setHtmlCode(htmlDom.innerText);
|
|
||||||
} else if (
|
|
||||||
refText?.startsWith('<!DOCTYPE') ||
|
|
||||||
refText?.startsWith('<svg') ||
|
|
||||||
refText?.startsWith('<?xml')
|
|
||||||
) {
|
|
||||||
setHtmlCode(refText);
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
|
|
||||||
// 处理代码块的换行
|
|
||||||
useEffect(() => {
|
|
||||||
if (ref.current) {
|
|
||||||
const codeElements = ref.current.querySelectorAll('code');
|
|
||||||
const wrapLanguages = [
|
|
||||||
'',
|
|
||||||
'md',
|
|
||||||
'markdown',
|
|
||||||
'text',
|
|
||||||
'txt',
|
|
||||||
'plaintext',
|
|
||||||
'tex',
|
|
||||||
'latex',
|
|
||||||
];
|
|
||||||
codeElements.forEach((codeElement) => {
|
|
||||||
let languageClass = codeElement.className.match(/language-(\w+)/);
|
|
||||||
let name = languageClass ? languageClass[1] : '';
|
|
||||||
if (wrapLanguages.includes(name)) {
|
|
||||||
codeElement.style.whiteSpace = 'pre-wrap';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setTimeout(renderArtifacts, 1);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<pre
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
backgroundColor: 'var(--semi-color-fill-0)',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '12px',
|
|
||||||
margin: '12px 0',
|
|
||||||
overflow: 'auto',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='copy-code-button'
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '8px',
|
|
||||||
right: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '4px',
|
|
||||||
zIndex: 10,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip content={t('复制代码')}>
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
icon={<IconCopy />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (ref.current) {
|
|
||||||
const codeElement = ref.current.querySelector('code');
|
|
||||||
const code = codeElement?.textContent ?? '';
|
|
||||||
copy(code).then((success) => {
|
|
||||||
if (success) {
|
|
||||||
Toast.success(t('代码已复制到剪贴板'));
|
|
||||||
} else {
|
|
||||||
Toast.error(t('复制失败,请手动复制'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '4px',
|
|
||||||
backgroundColor: 'var(--semi-color-bg-2)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{props.children}
|
|
||||||
</pre>
|
|
||||||
{mermaidCode.length > 0 && (
|
|
||||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
|
||||||
)}
|
|
||||||
{htmlCode.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '16px',
|
|
||||||
margin: '12px 0',
|
|
||||||
backgroundColor: 'var(--semi-color-bg-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
HTML预览:
|
|
||||||
</div>
|
|
||||||
<SandboxedHtmlPreview code={htmlCode} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomCode(props) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
|
||||||
const [showToggle, setShowToggle] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ref.current) {
|
|
||||||
const codeHeight = ref.current.scrollHeight;
|
|
||||||
setShowToggle(codeHeight > 400);
|
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [props.children]);
|
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
|
||||||
setCollapsed((collapsed) => !collapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderShowMoreButton = () => {
|
|
||||||
if (showToggle && collapsed) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '8px',
|
|
||||||
right: '8px',
|
|
||||||
left: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button size='small' onClick={toggleCollapsed} theme='solid'>
|
|
||||||
{t('显示更多')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<code
|
|
||||||
className={clsx(props?.className)}
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
maxHeight: collapsed ? '400px' : 'none',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
display: 'block',
|
|
||||||
padding: '8px 12px',
|
|
||||||
backgroundColor: 'var(--semi-color-fill-0)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</code>
|
|
||||||
{renderShowMoreButton()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeBrackets(text) {
|
|
||||||
const pattern =
|
|
||||||
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
|
|
||||||
return text.replace(
|
|
||||||
pattern,
|
|
||||||
(match, codeBlock, squareBracket, roundBracket) => {
|
|
||||||
if (codeBlock) {
|
|
||||||
return codeBlock;
|
|
||||||
} else if (squareBracket) {
|
|
||||||
return `$$${squareBracket}$$`;
|
|
||||||
} else if (roundBracket) {
|
|
||||||
return `$${roundBracket}$`;
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryWrapHtmlCode(text) {
|
|
||||||
// 尝试包装HTML代码
|
|
||||||
if (text.includes('```')) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
.replace(
|
|
||||||
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
|
||||||
(match, quoteStart, lang, newLine, doctype) => {
|
|
||||||
return !quoteStart ? '\n```html\n' + doctype : match;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
|
|
||||||
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
|
|
||||||
return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _MarkdownContent(props) {
|
|
||||||
const {
|
|
||||||
content,
|
|
||||||
className,
|
|
||||||
animated = false,
|
|
||||||
previousContentLength = 0,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const escapedContent = useMemo(() => {
|
|
||||||
return tryWrapHtmlCode(escapeBrackets(content));
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
// 判断是否为用户消息
|
|
||||||
const isUserMessage = className && className.includes('user-message');
|
|
||||||
|
|
||||||
const rehypePluginsBase = useMemo(() => {
|
|
||||||
const base = [
|
|
||||||
RehypeKatex,
|
|
||||||
[
|
|
||||||
RehypeHighlight,
|
|
||||||
{
|
|
||||||
detect: false,
|
|
||||||
ignoreMissing: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
if (animated) {
|
|
||||||
base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}, [animated, previousContentLength]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
|
||||||
rehypePlugins={rehypePluginsBase}
|
|
||||||
components={{
|
|
||||||
pre: PreCode,
|
|
||||||
code: CustomCode,
|
|
||||||
p: (pProps) => (
|
|
||||||
<p
|
|
||||||
{...pProps}
|
|
||||||
dir='auto'
|
|
||||||
style={{
|
|
||||||
lineHeight: '1.6',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
a: (aProps) => {
|
|
||||||
const href = aProps.href || '';
|
|
||||||
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
|
||||||
return (
|
|
||||||
<figure style={{ margin: '12px 0' }}>
|
|
||||||
<audio controls src={href} style={{ width: '100%' }}></audio>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
|
|
||||||
>
|
|
||||||
<source src={href} />
|
|
||||||
</video>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const isInternal = /^\/#/i.test(href);
|
|
||||||
const target = isInternal ? '_self' : (aProps.target ?? '_blank');
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
{...aProps}
|
|
||||||
target={target}
|
|
||||||
style={{
|
|
||||||
color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.textDecoration = 'underline';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.textDecoration = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
h1: (props) => (
|
|
||||||
<h1
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '20px 0 12px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h2: (props) => (
|
|
||||||
<h2
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '20px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '18px 0 10px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h3: (props) => (
|
|
||||||
<h3
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '16px 0 8px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h4: (props) => (
|
|
||||||
<h4
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '14px 0 6px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h5: (props) => (
|
|
||||||
<h5
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '12px 0 4px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
h6: (props) => (
|
|
||||||
<h6
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '10px 0 4px 0',
|
|
||||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
blockquote: (props) => (
|
|
||||||
<blockquote
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
borderLeft: isUserMessage
|
|
||||||
? '4px solid rgba(255, 255, 255, 0.5)'
|
|
||||||
: '4px solid var(--semi-color-primary)',
|
|
||||||
paddingLeft: '16px',
|
|
||||||
margin: '12px 0',
|
|
||||||
backgroundColor: isUserMessage
|
|
||||||
? 'rgba(255, 255, 255, 0.1)'
|
|
||||||
: 'var(--semi-color-fill-0)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '0 4px 4px 0',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
ul: (props) => (
|
|
||||||
<ul
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
margin: '8px 0',
|
|
||||||
paddingLeft: '20px',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
ol: (props) => (
|
|
||||||
<ol
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
margin: '8px 0',
|
|
||||||
paddingLeft: '20px',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
li: (props) => (
|
|
||||||
<li
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
margin: '4px 0',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
table: (props) => (
|
|
||||||
<div style={{ overflow: 'auto', margin: '12px 0' }}>
|
|
||||||
<table
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
border: isUserMessage
|
|
||||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
|
||||||
: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
th: (props) => (
|
|
||||||
<th
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
backgroundColor: isUserMessage
|
|
||||||
? 'rgba(255, 255, 255, 0.2)'
|
|
||||||
: 'var(--semi-color-fill-1)',
|
|
||||||
border: isUserMessage
|
|
||||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
|
||||||
: '1px solid var(--semi-color-border)',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'left',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
td: (props) => (
|
|
||||||
<td
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: isUserMessage
|
|
||||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
|
||||||
: '1px solid var(--semi-color-border)',
|
|
||||||
color: isUserMessage ? 'white' : 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{escapedContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkdownContent = React.memo(_MarkdownContent);
|
|
||||||
|
|
||||||
export function MarkdownRenderer(props) {
|
|
||||||
const {
|
|
||||||
content,
|
|
||||||
loading,
|
|
||||||
fontSize = 14,
|
|
||||||
fontFamily = 'inherit',
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
animated = false,
|
|
||||||
previousContentLength = 0,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx('markdown-body', className)}
|
|
||||||
style={{
|
|
||||||
fontSize: `${fontSize}px`,
|
|
||||||
fontFamily: fontFamily,
|
|
||||||
lineHeight: '1.6',
|
|
||||||
color: 'var(--semi-color-text-0)',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
dir='auto'
|
|
||||||
{...otherProps}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '16px',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
border: '2px solid var(--semi-color-border)',
|
|
||||||
borderTop: '2px solid var(--semi-color-primary)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
正在渲染...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<MarkdownContent
|
|
||||||
content={content}
|
|
||||||
className={className}
|
|
||||||
animated={animated}
|
|
||||||
previousContentLength={previousContentLength}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MarkdownRenderer;
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
/* 基础markdown样式 */
|
|
||||||
.markdown-body {
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--semi-color-text-0);
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 用户消息样式 - 白色字体适配蓝色背景 */
|
|
||||||
.user-message {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message .markdown-body {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message h1,
|
|
||||||
.user-message h2,
|
|
||||||
.user-message h3,
|
|
||||||
.user-message h4,
|
|
||||||
.user-message h5,
|
|
||||||
.user-message h6 {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message p {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message span {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message div {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message li {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message td,
|
|
||||||
.user-message th {
|
|
||||||
color: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message blockquote {
|
|
||||||
color: white !important;
|
|
||||||
border-left-color: rgba(255, 255, 255, 0.5) !important;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message code:not(pre code) {
|
|
||||||
color: #000 !important;
|
|
||||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message a {
|
|
||||||
color: #87ceeb !important;
|
|
||||||
/* 浅蓝色链接 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message a:hover {
|
|
||||||
color: #b0e0e6 !important;
|
|
||||||
/* hover时更浅的蓝色 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格在用户消息中的样式 */
|
|
||||||
.user-message table {
|
|
||||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message th {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2) !important;
|
|
||||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message td {
|
|
||||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 加载动画 */
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 代码高亮主题 - 适配Semi Design */
|
|
||||||
.hljs {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--semi-color-text-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-comment,
|
|
||||||
.hljs-quote {
|
|
||||||
color: var(--semi-color-text-2);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-keyword,
|
|
||||||
.hljs-selector-tag,
|
|
||||||
.hljs-subst {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-number,
|
|
||||||
.hljs-literal,
|
|
||||||
.hljs-variable,
|
|
||||||
.hljs-template-variable,
|
|
||||||
.hljs-tag .hljs-attr {
|
|
||||||
color: var(--semi-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-string,
|
|
||||||
.hljs-doctag {
|
|
||||||
color: var(--semi-color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-title,
|
|
||||||
.hljs-section,
|
|
||||||
.hljs-selector-id {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-subst {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-type,
|
|
||||||
.hljs-class .hljs-title {
|
|
||||||
color: var(--semi-color-info);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-tag,
|
|
||||||
.hljs-name,
|
|
||||||
.hljs-attribute {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-regexp,
|
|
||||||
.hljs-link {
|
|
||||||
color: var(--semi-color-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-symbol,
|
|
||||||
.hljs-bullet {
|
|
||||||
color: var(--semi-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-built_in,
|
|
||||||
.hljs-builtin-name {
|
|
||||||
color: var(--semi-color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-meta {
|
|
||||||
color: var(--semi-color-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-deletion {
|
|
||||||
background: var(--semi-color-danger-light-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-addition {
|
|
||||||
background: var(--semi-color-success-light-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-emphasis {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hljs-strong {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mermaid容器样式 */
|
|
||||||
.mermaid-container {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-container:hover {
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 代码块样式增强 */
|
|
||||||
pre {
|
|
||||||
position: relative;
|
|
||||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre:hover {
|
|
||||||
border-color: var(--semi-color-primary) !important;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre:hover .copy-code-button {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-code-button {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-code-button:hover {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-code-button button {
|
|
||||||
pointer-events: auto !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确保按钮可点击 */
|
|
||||||
.copy-code-button .semi-button {
|
|
||||||
pointer-events: auto !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-code-button .semi-button:hover {
|
|
||||||
background-color: var(--semi-color-fill-1) !important;
|
|
||||||
border-color: var(--semi-color-primary) !important;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格响应式 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.markdown-body table {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body th,
|
|
||||||
.markdown-body td {
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 数学公式样式 */
|
|
||||||
.katex {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.katex-display {
|
|
||||||
margin: 1em 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 链接hover效果 */
|
|
||||||
.markdown-body a {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 引用块样式增强 */
|
|
||||||
.markdown-body blockquote {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body blockquote::before {
|
|
||||||
content: '"';
|
|
||||||
position: absolute;
|
|
||||||
left: -8px;
|
|
||||||
top: -8px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表样式增强 */
|
|
||||||
.markdown-body ul li::marker {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body ol li::marker {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分隔线样式 */
|
|
||||||
.markdown-body hr {
|
|
||||||
border: none;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent,
|
|
||||||
var(--semi-color-border),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
margin: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 图片样式 */
|
|
||||||
.markdown-body img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 内联代码样式 */
|
|
||||||
.markdown-body code:not(pre code) {
|
|
||||||
background-color: var(--semi-color-fill-1);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 标题锚点样式 */
|
|
||||||
.markdown-body h1:hover,
|
|
||||||
.markdown-body h2:hover,
|
|
||||||
.markdown-body h3:hover,
|
|
||||||
.markdown-body h4:hover,
|
|
||||||
.markdown-body h5:hover,
|
|
||||||
.markdown-body h6:hover {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 任务列表样式 */
|
|
||||||
.markdown-body input[type='checkbox'] {
|
|
||||||
margin-right: 8px;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body li.task-list-item {
|
|
||||||
list-style: none;
|
|
||||||
margin-left: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 键盘按键样式 */
|
|
||||||
.markdown-body kbd {
|
|
||||||
background-color: var(--semi-color-fill-0);
|
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: 0 1px 0 var(--semi-color-border);
|
|
||||||
color: var(--semi-color-text-0);
|
|
||||||
display: inline-block;
|
|
||||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 2px 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 详情折叠样式 */
|
|
||||||
.markdown-body details {
|
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body summary:hover {
|
|
||||||
color: var(--semi-color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 脚注样式 */
|
|
||||||
.markdown-body .footnote-ref {
|
|
||||||
color: var(--semi-color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body .footnote-ref:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 警告块样式 */
|
|
||||||
.markdown-body .warning {
|
|
||||||
background-color: var(--semi-color-warning-light-default);
|
|
||||||
border-left: 4px solid var(--semi-color-warning);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body .info {
|
|
||||||
background-color: var(--semi-color-info-light-default);
|
|
||||||
border-left: 4px solid var(--semi-color-info);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body .success {
|
|
||||||
background-color: var(--semi-color-success-light-default);
|
|
||||||
border-left: 4px solid var(--semi-color-success);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body .danger {
|
|
||||||
background-color: var(--semi-color-danger-light-default);
|
|
||||||
border-left: 4px solid var(--semi-color-danger);
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px) scale(0.98);
|
|
||||||
filter: blur(3px);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
opacity: 0.85;
|
|
||||||
filter: blur(0.5px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
filter: blur(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
||||||
will-change: opacity, transform;
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
Space,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
|
||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
|
||||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
const RiskMarkdownBlock = React.memo(function RiskMarkdownBlock({
|
|
||||||
markdownContent,
|
|
||||||
}) {
|
|
||||||
if (!markdownContent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='rounded-lg'
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
|
||||||
padding: '12px',
|
|
||||||
contentVisibility: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer content={markdownContent} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
markdownContent = '',
|
|
||||||
detailTitle = '',
|
|
||||||
detailItems = [],
|
|
||||||
checklist = [],
|
|
||||||
inputPrompt = '',
|
|
||||||
requiredText = '',
|
|
||||||
requiredTextParts = [],
|
|
||||||
inputPlaceholder = '',
|
|
||||||
mismatchText = '',
|
|
||||||
cancelText = '',
|
|
||||||
confirmText = '',
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}) {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [checkedItems, setCheckedItems] = useState([]);
|
|
||||||
const [typedText, setTypedText] = useState('');
|
|
||||||
const [typedTextParts, setTypedTextParts] = useState([]);
|
|
||||||
|
|
||||||
const normalizedRequiredTextParts = useMemo(() => {
|
|
||||||
let inputIndex = 0;
|
|
||||||
return requiredTextParts.map((part) => {
|
|
||||||
if (part.type === 'input') {
|
|
||||||
const normalizedPart = { ...part, inputIndex };
|
|
||||||
inputIndex += 1;
|
|
||||||
return normalizedPart;
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
}, [requiredTextParts]);
|
|
||||||
|
|
||||||
const requiredTextInputCount = useMemo(
|
|
||||||
() =>
|
|
||||||
normalizedRequiredTextParts.filter((part) => part.type === 'input')
|
|
||||||
.length,
|
|
||||||
[normalizedRequiredTextParts],
|
|
||||||
);
|
|
||||||
const hasSegmentedRequiredText = requiredTextInputCount > 0;
|
|
||||||
const requiredTextToDisplay = hasSegmentedRequiredText
|
|
||||||
? normalizedRequiredTextParts.map((part) => part.text).join('')
|
|
||||||
: requiredText;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
setCheckedItems(Array(checklist.length).fill(false));
|
|
||||||
setTypedText('');
|
|
||||||
setTypedTextParts(Array(requiredTextInputCount).fill(''));
|
|
||||||
}, [visible, checklist.length, requiredTextInputCount]);
|
|
||||||
|
|
||||||
const allChecked = useMemo(() => {
|
|
||||||
if (checklist.length === 0) return true;
|
|
||||||
return (
|
|
||||||
checkedItems.length === checklist.length && checkedItems.every(Boolean)
|
|
||||||
);
|
|
||||||
}, [checkedItems, checklist.length]);
|
|
||||||
|
|
||||||
const typedMatched = useMemo(() => {
|
|
||||||
if (hasSegmentedRequiredText) {
|
|
||||||
return normalizedRequiredTextParts.every((part) => {
|
|
||||||
if (part.type === 'static') return true;
|
|
||||||
return (
|
|
||||||
typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!requiredText) return true;
|
|
||||||
return typedText.trim() === requiredText.trim();
|
|
||||||
}, [
|
|
||||||
hasSegmentedRequiredText,
|
|
||||||
normalizedRequiredTextParts,
|
|
||||||
requiredText,
|
|
||||||
typedText,
|
|
||||||
typedTextParts,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
|
|
||||||
const hasTypedRequiredText = hasSegmentedRequiredText
|
|
||||||
? typedTextParts.some((part) => part.trim() !== '')
|
|
||||||
: typedText.length > 0;
|
|
||||||
const canConfirm = allChecked && typedMatched;
|
|
||||||
|
|
||||||
const handleChecklistChange = useCallback((index, checked) => {
|
|
||||||
setCheckedItems((previous) => {
|
|
||||||
const next = [...previous];
|
|
||||||
next[index] = checked;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTextPartChange = useCallback((index, value) => {
|
|
||||||
setTypedTextParts((previous) => {
|
|
||||||
const next = [...previous];
|
|
||||||
next[index] = value;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
title={
|
|
||||||
<Space align='center'>
|
|
||||||
<IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
|
|
||||||
<span>{title}</span>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
width={isMobile ? '100%' : 860}
|
|
||||||
centered
|
|
||||||
maskClosable={false}
|
|
||||||
closeOnEsc={false}
|
|
||||||
onCancel={onCancel}
|
|
||||||
bodyStyle={{
|
|
||||||
maxHeight: isMobile ? '70vh' : '72vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
padding: isMobile ? '12px 16px' : '18px 22px',
|
|
||||||
}}
|
|
||||||
footer={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={onCancel}>{cancelText}</Button>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='danger'
|
|
||||||
disabled={!canConfirm}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<RiskMarkdownBlock markdownContent={markdownContent} />
|
|
||||||
|
|
||||||
{detailItems.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-2 rounded-lg'
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
|
||||||
background: 'var(--semi-color-fill-0)',
|
|
||||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{detailTitle ? <Text strong>{detailTitle}</Text> : null}
|
|
||||||
<div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
|
|
||||||
{detailText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{checklist.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-2 rounded-lg'
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
background: 'var(--semi-color-fill-0)',
|
|
||||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{checklist.map((item, index) => (
|
|
||||||
<Checkbox
|
|
||||||
key={`risk-check-${index}`}
|
|
||||||
checked={!!checkedItems[index]}
|
|
||||||
onChange={(event) => {
|
|
||||||
handleChecklistChange(index, event.target.checked);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Checkbox>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{requiredTextToDisplay ? (
|
|
||||||
<div
|
|
||||||
className='flex flex-col gap-2 rounded-lg'
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--semi-color-danger-light-hover)',
|
|
||||||
background: 'var(--semi-color-danger-light-default)',
|
|
||||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
|
|
||||||
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
|
|
||||||
{requiredTextToDisplay}
|
|
||||||
</div>
|
|
||||||
{hasSegmentedRequiredText ? (
|
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
|
||||||
{normalizedRequiredTextParts.map((part, index) =>
|
|
||||||
part.type === 'static' ? (
|
|
||||||
<span
|
|
||||||
key={`static-${index}`}
|
|
||||||
className='select-none rounded-md border border-gray-200 bg-white px-2 py-1 font-mono text-sm text-gray-500'
|
|
||||||
>
|
|
||||||
{part.text}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
key={`input-${index}`}
|
|
||||||
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
handleTextPartChange(part.inputIndex ?? 0, value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
part.placeholder || part.text || inputPlaceholder
|
|
||||||
}
|
|
||||||
autoFocus={visible && part.inputIndex === 0}
|
|
||||||
onCopy={(event) => event.preventDefault()}
|
|
||||||
onCut={(event) => event.preventDefault()}
|
|
||||||
onPaste={(event) => event.preventDefault()}
|
|
||||||
onDrop={(event) => event.preventDefault()}
|
|
||||||
style={{ width: isMobile ? '100%' : 260 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={typedText}
|
|
||||||
onChange={setTypedText}
|
|
||||||
placeholder={inputPlaceholder}
|
|
||||||
autoFocus={visible}
|
|
||||||
onCopy={(event) => event.preventDefault()}
|
|
||||||
onCut={(event) => event.preventDefault()}
|
|
||||||
onPaste={(event) => event.preventDefault()}
|
|
||||||
onDrop={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!typedMatched && hasTypedRequiredText ? (
|
|
||||||
<Text type='danger' size='small'>
|
|
||||||
{mismatchText}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RiskAcknowledgementModal;
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
Tabs,
|
|
||||||
TabPane,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用安全验证模态框组件
|
|
||||||
* 配合 useSecureVerification Hook 使用
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {boolean} props.visible - 是否显示模态框
|
|
||||||
* @param {Object} props.verificationMethods - 可用的验证方式
|
|
||||||
* @param {Object} props.verificationState - 当前验证状态
|
|
||||||
* @param {Function} props.onVerify - 验证回调
|
|
||||||
* @param {Function} props.onCancel - 取消回调
|
|
||||||
* @param {Function} props.onCodeChange - 验证码变化回调
|
|
||||||
* @param {Function} props.onMethodSwitch - 验证方式切换回调
|
|
||||||
* @param {string} props.title - 模态框标题
|
|
||||||
* @param {string} props.description - 验证描述文本
|
|
||||||
*/
|
|
||||||
const SecureVerificationModal = ({
|
|
||||||
visible,
|
|
||||||
verificationMethods,
|
|
||||||
verificationState,
|
|
||||||
onVerify,
|
|
||||||
onCancel,
|
|
||||||
onCodeChange,
|
|
||||||
onMethodSwitch,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
|
||||||
const [verifySuccess, setVerifySuccess] = useState(false);
|
|
||||||
|
|
||||||
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
|
||||||
const { method, loading, code } = verificationState;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
setIsAnimating(true);
|
|
||||||
setVerifySuccess(false);
|
|
||||||
} else {
|
|
||||||
setIsAnimating(false);
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
|
||||||
onVerify(method, code);
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' && !loading) {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果用户没有启用任何验证方式
|
|
||||||
if (visible && !has2FA && !hasPasskey) {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={title || t('安全验证')}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={onCancel}
|
|
||||||
footer={<Button onClick={onCancel}>{t('确定')}</Button>}
|
|
||||||
width={500}
|
|
||||||
style={{ maxWidth: '90vw' }}
|
|
||||||
>
|
|
||||||
<div className='text-center py-6'>
|
|
||||||
<div className='mb-4'>
|
|
||||||
<svg
|
|
||||||
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<Typography.Title heading={4} className='mb-2'>
|
|
||||||
{t('需要安全验证')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type='tertiary'>
|
|
||||||
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
|
|
||||||
</Typography.Text>
|
|
||||||
<br />
|
|
||||||
<Typography.Text type='tertiary'>
|
|
||||||
{t('请前往个人设置 → 安全设置进行配置。')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={title || t('安全验证')}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={loading ? undefined : onCancel}
|
|
||||||
closeOnEsc={!loading}
|
|
||||||
footer={null}
|
|
||||||
width={460}
|
|
||||||
centered
|
|
||||||
style={{
|
|
||||||
maxWidth: 'calc(100vw - 32px)',
|
|
||||||
}}
|
|
||||||
bodyStyle={{
|
|
||||||
padding: '20px 24px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
{/* 描述信息 */}
|
|
||||||
{description && (
|
|
||||||
<Typography.Paragraph
|
|
||||||
type='tertiary'
|
|
||||||
style={{
|
|
||||||
margin: '0 0 20px 0',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 验证方式选择 */}
|
|
||||||
<Tabs
|
|
||||||
activeKey={method}
|
|
||||||
onChange={onMethodSwitch}
|
|
||||||
type='line'
|
|
||||||
size='default'
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
>
|
|
||||||
{has2FA && (
|
|
||||||
<TabPane tab={t('两步验证')} itemKey='2fa'>
|
|
||||||
<div style={{ paddingTop: '20px' }}>
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<Input
|
|
||||||
placeholder={t('请输入6位验证码或8位备用码')}
|
|
||||||
value={code}
|
|
||||||
onChange={onCodeChange}
|
|
||||||
size='large'
|
|
||||||
maxLength={8}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
autoFocus={method === '2fa'}
|
|
||||||
disabled={loading}
|
|
||||||
prefix={
|
|
||||||
<svg
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
marginRight: 8,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Typography.Text
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: '20px',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('从认证器应用中获取验证码,或使用备用码')}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: '8px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onClick={onCancel} disabled={loading}>
|
|
||||||
{t('取消')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
loading={loading}
|
|
||||||
disabled={!code.trim() || loading}
|
|
||||||
onClick={() => onVerify(method, code)}
|
|
||||||
>
|
|
||||||
{t('验证')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPasskey && passkeySupported && (
|
|
||||||
<TabPane tab={t('Passkey')} itemKey='passkey'>
|
|
||||||
<div style={{ paddingTop: '20px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '24px 16px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
margin: '0 auto 16px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'var(--semi-color-primary-light-default)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
style={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
color: 'var(--semi-color-primary)',
|
|
||||||
}}
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<Typography.Title
|
|
||||||
heading={5}
|
|
||||||
style={{ margin: '0 0 8px', fontSize: '16px' }}
|
|
||||||
>
|
|
||||||
{t('使用 Passkey 验证')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text
|
|
||||||
type='tertiary'
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('点击验证按钮,使用您的生物特征或安全密钥')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: '8px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onClick={onCancel} disabled={loading}>
|
|
||||||
{t('取消')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => onVerify(method)}
|
|
||||||
>
|
|
||||||
{t('验证 Passkey')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SecureVerificationModal;
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 可复用的两步验证模态框组件
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {boolean} props.visible - 是否显示模态框
|
|
||||||
* @param {string} props.code - 验证码值
|
|
||||||
* @param {boolean} props.loading - 是否正在验证
|
|
||||||
* @param {Function} props.onCodeChange - 验证码变化回调
|
|
||||||
* @param {Function} props.onVerify - 验证回调
|
|
||||||
* @param {Function} props.onCancel - 取消回调
|
|
||||||
* @param {string} props.title - 模态框标题
|
|
||||||
* @param {string} props.description - 验证描述文本
|
|
||||||
* @param {string} props.placeholder - 输入框占位文本
|
|
||||||
*/
|
|
||||||
const TwoFactorAuthModal = ({
|
|
||||||
visible,
|
|
||||||
code,
|
|
||||||
loading,
|
|
||||||
onCodeChange,
|
|
||||||
onVerify,
|
|
||||||
onCancel,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
placeholder,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Enter' && code && !loading) {
|
|
||||||
onVerify();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
|
||||||
<svg
|
|
||||||
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{title || t('安全验证')}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={onCancel}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
|
||||||
<Button
|
|
||||||
type='primary'
|
|
||||||
loading={loading}
|
|
||||||
disabled={!code || loading}
|
|
||||||
onClick={onVerify}
|
|
||||||
>
|
|
||||||
{t('验证')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
width={500}
|
|
||||||
style={{ maxWidth: '90vw' }}
|
|
||||||
>
|
|
||||||
<div className='space-y-6'>
|
|
||||||
{/* 安全提示 */}
|
|
||||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
|
||||||
<div className='flex items-start'>
|
|
||||||
<svg
|
|
||||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
className='text-blue-800 dark:text-blue-200'
|
|
||||||
>
|
|
||||||
{t('安全验证')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
|
||||||
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 验证码输入 */}
|
|
||||||
<div>
|
|
||||||
<Typography.Text strong className='block mb-2'>
|
|
||||||
{t('验证身份')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
|
||||||
value={code}
|
|
||||||
onChange={onCodeChange}
|
|
||||||
size='large'
|
|
||||||
maxLength={8}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
|
||||||
{t(
|
|
||||||
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
|
||||||
)}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TwoFactorAuthModal;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
|
||||||
import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CardPro 高级卡片组件
|
|
||||||
*
|
|
||||||
* 布局分为6个区域:
|
|
||||||
* 1. 统计信息区域 (statsArea)
|
|
||||||
* 2. 描述信息区域 (descriptionArea)
|
|
||||||
* 3. 类型切换/标签区域 (tabsArea)
|
|
||||||
* 4. 操作按钮区域 (actionsArea)
|
|
||||||
* 5. 搜索表单区域 (searchArea)
|
|
||||||
* 6. 分页区域 (paginationArea) - 固定在卡片底部
|
|
||||||
*
|
|
||||||
* 支持三种布局类型:
|
|
||||||
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
|
|
||||||
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
|
|
||||||
* - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单
|
|
||||||
*/
|
|
||||||
const CardPro = ({
|
|
||||||
type = 'type1',
|
|
||||||
className = '',
|
|
||||||
children,
|
|
||||||
// 各个区域的内容
|
|
||||||
statsArea,
|
|
||||||
descriptionArea,
|
|
||||||
tabsArea,
|
|
||||||
actionsArea,
|
|
||||||
searchArea,
|
|
||||||
paginationArea, // 新增分页区域
|
|
||||||
// 卡片属性
|
|
||||||
shadows = '',
|
|
||||||
bordered = true,
|
|
||||||
// 自定义样式
|
|
||||||
style,
|
|
||||||
// 国际化函数
|
|
||||||
t = (key) => key,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
|
||||||
|
|
||||||
const toggleMobileActions = () => {
|
|
||||||
setShowMobileActions(!showMobileActions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasMobileHideableContent = actionsArea || searchArea;
|
|
||||||
|
|
||||||
const renderHeader = () => {
|
|
||||||
const hasContent =
|
|
||||||
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
|
||||||
if (!hasContent) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col w-full'>
|
|
||||||
{/* 统计信息区域 - 用于type2 */}
|
|
||||||
{type === 'type2' && statsArea && <>{statsArea}</>}
|
|
||||||
|
|
||||||
{/* 描述信息区域 - 用于type1和type3 */}
|
|
||||||
{(type === 'type1' || type === 'type3') && descriptionArea && (
|
|
||||||
<>{descriptionArea}</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
|
|
||||||
{((type === 'type1' || type === 'type3') && descriptionArea) ||
|
|
||||||
(type === 'type2' && statsArea) ? (
|
|
||||||
<Divider margin='12px' />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 类型切换/标签区域 - 主要用于type3 */}
|
|
||||||
{type === 'type3' && tabsArea && <>{tabsArea}</>}
|
|
||||||
|
|
||||||
{/* 移动端操作切换按钮 */}
|
|
||||||
{isMobile && hasMobileHideableContent && (
|
|
||||||
<>
|
|
||||||
<div className='w-full mb-2'>
|
|
||||||
<Button
|
|
||||||
onClick={toggleMobileActions}
|
|
||||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
theme='outline'
|
|
||||||
block
|
|
||||||
>
|
|
||||||
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮和搜索表单的容器 */}
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
|
|
||||||
>
|
|
||||||
{/* 操作按钮区域 - 用于type1和type3 */}
|
|
||||||
{(type === 'type1' || type === 'type3') &&
|
|
||||||
actionsArea &&
|
|
||||||
(Array.isArray(actionsArea) ? (
|
|
||||||
actionsArea.map((area, idx) => (
|
|
||||||
<React.Fragment key={idx}>
|
|
||||||
{idx !== 0 && <Divider />}
|
|
||||||
<div className='w-full'>{area}</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className='w-full'>{actionsArea}</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
|
|
||||||
{actionsArea && searchArea && <Divider />}
|
|
||||||
|
|
||||||
{/* 搜索表单区域 - 所有类型都可能有 */}
|
|
||||||
{searchArea && <div className='w-full'>{searchArea}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerContent = renderHeader();
|
|
||||||
|
|
||||||
// 渲染分页区域
|
|
||||||
const renderFooter = () => {
|
|
||||||
if (!paginationArea) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex w-full pt-4 border-t ${isMobile ? 'justify-center' : 'justify-between items-center'}`}
|
|
||||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
|
||||||
>
|
|
||||||
{paginationArea}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const footerContent = renderFooter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={`table-scroll-card !rounded-2xl ${className}`}
|
|
||||||
title={headerContent}
|
|
||||||
footer={footerContent}
|
|
||||||
shadows={shadows}
|
|
||||||
bordered={bordered}
|
|
||||||
style={style}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CardPro.propTypes = {
|
|
||||||
// 布局类型
|
|
||||||
type: PropTypes.oneOf(['type1', 'type2', 'type3']),
|
|
||||||
// 样式相关
|
|
||||||
className: PropTypes.string,
|
|
||||||
style: PropTypes.object,
|
|
||||||
shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
|
||||||
bordered: PropTypes.bool,
|
|
||||||
// 内容区域
|
|
||||||
statsArea: PropTypes.node,
|
|
||||||
descriptionArea: PropTypes.node,
|
|
||||||
tabsArea: PropTypes.node,
|
|
||||||
actionsArea: PropTypes.oneOfType([
|
|
||||||
PropTypes.node,
|
|
||||||
PropTypes.arrayOf(PropTypes.node),
|
|
||||||
]),
|
|
||||||
searchArea: PropTypes.node,
|
|
||||||
paginationArea: PropTypes.node,
|
|
||||||
// 表格内容
|
|
||||||
children: PropTypes.node,
|
|
||||||
// 国际化函数
|
|
||||||
t: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CardPro;
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Card,
|
|
||||||
Skeleton,
|
|
||||||
Pagination,
|
|
||||||
Empty,
|
|
||||||
Button,
|
|
||||||
Collapsible,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
|
||||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CardTable 响应式表格组件
|
|
||||||
*
|
|
||||||
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
|
|
||||||
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
|
|
||||||
*/
|
|
||||||
const CardTable = ({
|
|
||||||
columns = [],
|
|
||||||
dataSource = [],
|
|
||||||
loading = false,
|
|
||||||
rowKey = 'key',
|
|
||||||
hidePagination = false,
|
|
||||||
...tableProps
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoadingTime(loading);
|
|
||||||
|
|
||||||
const getRowKey = (record, index) => {
|
|
||||||
if (typeof rowKey === 'function') return rowKey(record);
|
|
||||||
return record[rowKey] !== undefined ? record[rowKey] : index;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
const finalTableProps = hidePagination
|
|
||||||
? { ...tableProps, pagination: false }
|
|
||||||
: tableProps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={dataSource}
|
|
||||||
loading={loading}
|
|
||||||
rowKey={rowKey}
|
|
||||||
{...finalTableProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSkeleton) {
|
|
||||||
const visibleCols = columns.filter((col) => {
|
|
||||||
if (tableProps?.visibleColumns && col.key) {
|
|
||||||
return tableProps.visibleColumns[col.key];
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderSkeletonCard = (key) => {
|
|
||||||
const placeholder = (
|
|
||||||
<div className='p-2'>
|
|
||||||
{visibleCols.map((col, idx) => {
|
|
||||||
if (!col.title) {
|
|
||||||
return (
|
|
||||||
<div key={idx} className='mt-2 flex justify-end'>
|
|
||||||
<Skeleton.Title active style={{ width: 100, height: 24 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
|
|
||||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
|
||||||
>
|
|
||||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
|
||||||
<Skeleton.Title
|
|
||||||
active
|
|
||||||
style={{
|
|
||||||
width: `${50 + (idx % 3) * 10}%`,
|
|
||||||
maxWidth: 180,
|
|
||||||
height: 14,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={key} className='!rounded-2xl shadow-sm'>
|
|
||||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
|
|
||||||
|
|
||||||
const MobileRowCard = ({ record, index }) => {
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
const rowKeyVal = getRowKey(record, index);
|
|
||||||
|
|
||||||
const hasDetails =
|
|
||||||
tableProps.expandedRowRender &&
|
|
||||||
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
|
|
||||||
{columns.map((col, colIdx) => {
|
|
||||||
if (
|
|
||||||
tableProps?.visibleColumns &&
|
|
||||||
!tableProps.visibleColumns[col.key]
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = col.title;
|
|
||||||
const cellContent = col.render
|
|
||||||
? col.render(record[col.dataIndex], record, index)
|
|
||||||
: record[col.dataIndex];
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return (
|
|
||||||
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
|
|
||||||
{cellContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.key || colIdx}
|
|
||||||
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
|
|
||||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
|
||||||
>
|
|
||||||
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
<div className='flex-1 break-all flex justify-end items-center gap-1'>
|
|
||||||
{cellContent !== undefined && cellContent !== null
|
|
||||||
? cellContent
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{hasDetails && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
size='small'
|
|
||||||
className='w-full flex justify-center mt-2'
|
|
||||||
icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowDetails(!showDetails);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showDetails ? t('收起') : t('详情')}
|
|
||||||
</Button>
|
|
||||||
<Collapsible isOpen={showDetails} keepDOM>
|
|
||||||
<div className='pt-2'>
|
|
||||||
{tableProps.expandedRowRender(record, index)}
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
if (tableProps.empty) return tableProps.empty;
|
|
||||||
return (
|
|
||||||
<div className='flex justify-center p-4'>
|
|
||||||
<Empty description='No Data' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
{dataSource.map((record, index) => (
|
|
||||||
<MobileRowCard
|
|
||||||
key={getRowKey(record, index)}
|
|
||||||
record={record}
|
|
||||||
index={index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
|
|
||||||
<div className='mt-2 flex justify-center'>
|
|
||||||
<Pagination {...tableProps.pagination} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CardTable.propTypes = {
|
|
||||||
columns: PropTypes.array.isRequired,
|
|
||||||
dataSource: PropTypes.array,
|
|
||||||
loading: PropTypes.bool,
|
|
||||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
||||||
hidePagination: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CardTable;
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui';
|
|
||||||
import { copy, showSuccess } from '../../../helpers';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析密钥数据,支持多种格式
|
|
||||||
* @param {string} keyData - 密钥数据
|
|
||||||
* @param {Function} t - 翻译函数
|
|
||||||
* @returns {Array} 解析后的密钥数组
|
|
||||||
*/
|
|
||||||
const parseChannelKeys = (keyData, t) => {
|
|
||||||
if (!keyData) return [];
|
|
||||||
|
|
||||||
const trimmed = keyData.trim();
|
|
||||||
|
|
||||||
// 检查是否是JSON数组格式(如Vertex AI)
|
|
||||||
if (trimmed.startsWith('[')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed.map((item, index) => ({
|
|
||||||
id: index,
|
|
||||||
content:
|
|
||||||
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
|
||||||
type: typeof item === 'string' ? 'text' : 'json',
|
|
||||||
label: `${t('密钥')} ${index + 1}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 如果解析失败,按普通文本处理
|
|
||||||
console.warn('Failed to parse JSON keys:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是多行密钥(按换行符分割)
|
|
||||||
const lines = trimmed.split('\n').filter((line) => line.trim());
|
|
||||||
if (lines.length > 1) {
|
|
||||||
return lines.map((line, index) => ({
|
|
||||||
id: index,
|
|
||||||
content: line.trim(),
|
|
||||||
type: 'text',
|
|
||||||
label: `${t('密钥')} ${index + 1}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单个密钥
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
content: trimmed,
|
|
||||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
|
||||||
label: t('密钥'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 可复用的密钥显示组件
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {string} props.keyData - 密钥数据
|
|
||||||
* @param {boolean} props.showSuccessIcon - 是否显示成功图标
|
|
||||||
* @param {string} props.successText - 成功文本
|
|
||||||
* @param {boolean} props.showWarning - 是否显示安全警告
|
|
||||||
* @param {string} props.warningText - 警告文本
|
|
||||||
*/
|
|
||||||
const ChannelKeyDisplay = ({
|
|
||||||
keyData,
|
|
||||||
showSuccessIcon = true,
|
|
||||||
successText,
|
|
||||||
showWarning = true,
|
|
||||||
warningText,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const parsedKeys = parseChannelKeys(keyData, t);
|
|
||||||
const isMultipleKeys = parsedKeys.length > 1;
|
|
||||||
|
|
||||||
const handleCopyAll = () => {
|
|
||||||
copy(keyData);
|
|
||||||
showSuccess(t('所有密钥已复制到剪贴板'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyKey = (content) => {
|
|
||||||
copy(content);
|
|
||||||
showSuccess(t('密钥已复制到剪贴板'));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{/* 成功状态 */}
|
|
||||||
{showSuccessIcon && (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<svg
|
|
||||||
className='w-5 h-5 text-green-600'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<Typography.Text strong className='text-green-700'>
|
|
||||||
{successText || t('验证成功')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 密钥内容 */}
|
|
||||||
<div className='space-y-3'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<Typography.Text strong>
|
|
||||||
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
|
|
||||||
</Typography.Text>
|
|
||||||
{isMultipleKeys && (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Typography.Text type='tertiary' size='small'>
|
|
||||||
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
|
|
||||||
</Typography.Text>
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
onClick={handleCopyAll}
|
|
||||||
>
|
|
||||||
{t('复制全部')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-3 max-h-80 overflow-auto'>
|
|
||||||
{parsedKeys.map((keyItem) => (
|
|
||||||
<Card
|
|
||||||
key={keyItem.id}
|
|
||||||
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
|
|
||||||
>
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
size='small'
|
|
||||||
className='text-gray-700 dark:text-gray-300'
|
|
||||||
>
|
|
||||||
{keyItem.label}
|
|
||||||
</Typography.Text>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{keyItem.type === 'json' && (
|
|
||||||
<Tag size='small' color='blue'>
|
|
||||||
{t('JSON')}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
icon={
|
|
||||||
<svg
|
|
||||||
className='w-3 h-3'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
|
|
||||||
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
onClick={() => handleCopyKey(keyItem.content)}
|
|
||||||
>
|
|
||||||
{t('复制')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
|
|
||||||
<Typography.Text
|
|
||||||
code
|
|
||||||
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
|
|
||||||
>
|
|
||||||
{keyItem.content}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{keyItem.type === 'json' && (
|
|
||||||
<Typography.Text
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
className='block'
|
|
||||||
>
|
|
||||||
{t('JSON格式密钥,请确保格式正确')}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isMultipleKeys && (
|
|
||||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
|
||||||
<Typography.Text
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
className='text-blue-700 dark:text-blue-300'
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className='w-4 h-4 inline mr-1'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{t(
|
|
||||||
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
|
|
||||||
)}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 安全警告 */}
|
|
||||||
{showWarning && (
|
|
||||||
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
|
|
||||||
<div className='flex items-start'>
|
|
||||||
<svg
|
|
||||||
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
|
|
||||||
fill='currentColor'
|
|
||||||
viewBox='0 0 20 20'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
|
||||||
clipRule='evenodd'
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
className='text-yellow-800 dark:text-yellow-200'
|
|
||||||
>
|
|
||||||
{t('安全提醒')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
|
|
||||||
{warningText ||
|
|
||||||
t(
|
|
||||||
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
|
|
||||||
)}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChannelKeyDisplay;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 紧凑模式切换按钮组件
|
|
||||||
* 用于在自适应列表和紧凑列表之间切换
|
|
||||||
* 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示
|
|
||||||
*/
|
|
||||||
const CompactModeToggle = ({
|
|
||||||
compactMode,
|
|
||||||
setCompactMode,
|
|
||||||
t,
|
|
||||||
size = 'small',
|
|
||||||
type = 'tertiary',
|
|
||||||
className = '',
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// 在移动端隐藏紧凑列表切换按钮
|
|
||||||
if (isMobile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type={type}
|
|
||||||
size={size}
|
|
||||||
className={`w-full md:w-auto ${className}`}
|
|
||||||
onClick={() => setCompactMode(!compactMode)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CompactModeToggle.propTypes = {
|
|
||||||
compactMode: PropTypes.bool.isRequired,
|
|
||||||
setCompactMode: PropTypes.func.isRequired,
|
|
||||||
t: PropTypes.func.isRequired,
|
|
||||||
size: PropTypes.string,
|
|
||||||
type: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CompactModeToggle;
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Form,
|
|
||||||
Typography,
|
|
||||||
Banner,
|
|
||||||
Tabs,
|
|
||||||
TabPane,
|
|
||||||
Card,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Switch,
|
|
||||||
TextArea,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Divider,
|
|
||||||
Tooltip,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
|
|
||||||
const generateUniqueId = (() => {
|
|
||||||
let counter = 0;
|
|
||||||
return () => `kv_${counter++}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const JSONEditor = ({
|
|
||||||
value = '',
|
|
||||||
onChange,
|
|
||||||
field,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
extraText,
|
|
||||||
extraFooter,
|
|
||||||
showClear = true,
|
|
||||||
template,
|
|
||||||
templateLabel,
|
|
||||||
editorType = 'keyValue',
|
|
||||||
rules = [],
|
|
||||||
formApi = null,
|
|
||||||
renderStringValueSuffix,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 将对象转换为键值对数组(包含唯一ID)
|
|
||||||
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
|
|
||||||
if (!obj || typeof obj !== 'object') return [];
|
|
||||||
|
|
||||||
const entries = Object.entries(obj);
|
|
||||||
return entries.map(([key, value], index) => {
|
|
||||||
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
|
|
||||||
const prev = prevPairs[index];
|
|
||||||
const shouldReuseId = prev && prev.key === key;
|
|
||||||
return {
|
|
||||||
id: shouldReuseId ? prev.id : generateUniqueId(),
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
|
||||||
const keyValueArrayToObject = useCallback((arr) => {
|
|
||||||
const result = {};
|
|
||||||
arr.forEach((item) => {
|
|
||||||
if (item.key) {
|
|
||||||
result[item.key] = item.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始化键值对数组
|
|
||||||
const [keyValuePairs, setKeyValuePairs] = useState(() => {
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return objectToKeyValueArray(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return objectToKeyValueArray(value);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 手动模式下的本地文本缓冲
|
|
||||||
const [manualText, setManualText] = useState(() => {
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
if (value && typeof value === 'object')
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 根据键数量决定默认编辑模式
|
|
||||||
const [editMode, setEditMode] = useState(() => {
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
const keyCount = Object.keys(parsed).length;
|
|
||||||
return keyCount > 10 ? 'manual' : 'visual';
|
|
||||||
} catch (error) {
|
|
||||||
return 'manual';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'visual';
|
|
||||||
});
|
|
||||||
|
|
||||||
const [jsonError, setJsonError] = useState('');
|
|
||||||
|
|
||||||
// 计算重复的键
|
|
||||||
const duplicateKeys = useMemo(() => {
|
|
||||||
const keyCount = {};
|
|
||||||
const duplicates = new Set();
|
|
||||||
|
|
||||||
keyValuePairs.forEach((pair) => {
|
|
||||||
if (pair.key) {
|
|
||||||
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
|
||||||
if (keyCount[pair.key] > 1) {
|
|
||||||
duplicates.add(pair.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return duplicates;
|
|
||||||
}, [keyValuePairs]);
|
|
||||||
|
|
||||||
// 数据同步 - 当value变化时更新键值对数组
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
let parsed = {};
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
parsed = JSON.parse(value);
|
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
|
||||||
parsed = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只在外部值真正改变时更新,避免循环更新
|
|
||||||
const currentObj = keyValueArrayToObject(keyValuePairs);
|
|
||||||
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
|
|
||||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
|
||||||
}
|
|
||||||
setJsonError('');
|
|
||||||
} catch (error) {
|
|
||||||
console.log('JSON解析失败:', error.message);
|
|
||||||
setJsonError(error.message);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// 外部 value 变化时,若不在手动模式,则同步手动文本
|
|
||||||
useEffect(() => {
|
|
||||||
if (editMode !== 'manual') {
|
|
||||||
if (typeof value === 'string') setManualText(value);
|
|
||||||
else if (value && typeof value === 'object')
|
|
||||||
setManualText(JSON.stringify(value, null, 2));
|
|
||||||
else setManualText('');
|
|
||||||
}
|
|
||||||
}, [value, editMode]);
|
|
||||||
|
|
||||||
// 处理可视化编辑的数据变化
|
|
||||||
const handleVisualChange = useCallback(
|
|
||||||
(newPairs) => {
|
|
||||||
setKeyValuePairs(newPairs);
|
|
||||||
const jsonObject = keyValueArrayToObject(newPairs);
|
|
||||||
const jsonString =
|
|
||||||
Object.keys(jsonObject).length === 0
|
|
||||||
? ''
|
|
||||||
: JSON.stringify(jsonObject, null, 2);
|
|
||||||
|
|
||||||
setJsonError('');
|
|
||||||
|
|
||||||
// 通过formApi设置值
|
|
||||||
if (formApi && field) {
|
|
||||||
formApi.setValue(field, jsonString);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange?.(jsonString);
|
|
||||||
},
|
|
||||||
[onChange, formApi, field, keyValueArrayToObject],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理手动编辑的数据变化
|
|
||||||
const handleManualChange = useCallback(
|
|
||||||
(newValue) => {
|
|
||||||
setManualText(newValue);
|
|
||||||
if (newValue && newValue.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(newValue);
|
|
||||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
|
||||||
setJsonError('');
|
|
||||||
onChange?.(newValue);
|
|
||||||
} catch (error) {
|
|
||||||
setJsonError(error.message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setKeyValuePairs([]);
|
|
||||||
setJsonError('');
|
|
||||||
onChange?.('');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onChange, objectToKeyValueArray, keyValuePairs],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 切换编辑模式
|
|
||||||
const toggleEditMode = useCallback(() => {
|
|
||||||
if (editMode === 'visual') {
|
|
||||||
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
|
||||||
setManualText(
|
|
||||||
Object.keys(jsonObject).length === 0
|
|
||||||
? ''
|
|
||||||
: JSON.stringify(jsonObject, null, 2),
|
|
||||||
);
|
|
||||||
setEditMode('manual');
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
let parsed = {};
|
|
||||||
if (manualText && manualText.trim()) {
|
|
||||||
parsed = JSON.parse(manualText);
|
|
||||||
} else if (typeof value === 'string' && value.trim()) {
|
|
||||||
parsed = JSON.parse(value);
|
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
|
||||||
parsed = value;
|
|
||||||
}
|
|
||||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
|
||||||
setJsonError('');
|
|
||||||
setEditMode('visual');
|
|
||||||
} catch (error) {
|
|
||||||
setJsonError(error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
editMode,
|
|
||||||
value,
|
|
||||||
manualText,
|
|
||||||
keyValuePairs,
|
|
||||||
keyValueArrayToObject,
|
|
||||||
objectToKeyValueArray,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 添加键值对
|
|
||||||
const addKeyValue = useCallback(() => {
|
|
||||||
const newPairs = [...keyValuePairs];
|
|
||||||
const existingKeys = newPairs.map((p) => p.key);
|
|
||||||
let counter = 1;
|
|
||||||
let newKey = `field_${counter}`;
|
|
||||||
while (existingKeys.includes(newKey)) {
|
|
||||||
counter += 1;
|
|
||||||
newKey = `field_${counter}`;
|
|
||||||
}
|
|
||||||
newPairs.push({
|
|
||||||
id: generateUniqueId(),
|
|
||||||
key: newKey,
|
|
||||||
value: '',
|
|
||||||
});
|
|
||||||
handleVisualChange(newPairs);
|
|
||||||
}, [keyValuePairs, handleVisualChange]);
|
|
||||||
|
|
||||||
// 删除键值对
|
|
||||||
const removeKeyValue = useCallback(
|
|
||||||
(id) => {
|
|
||||||
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
|
|
||||||
handleVisualChange(newPairs);
|
|
||||||
},
|
|
||||||
[keyValuePairs, handleVisualChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新键名
|
|
||||||
const updateKey = useCallback(
|
|
||||||
(id, newKey) => {
|
|
||||||
const newPairs = keyValuePairs.map((pair) =>
|
|
||||||
pair.id === id ? { ...pair, key: newKey } : pair,
|
|
||||||
);
|
|
||||||
handleVisualChange(newPairs);
|
|
||||||
},
|
|
||||||
[keyValuePairs, handleVisualChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新值
|
|
||||||
const updateValue = useCallback(
|
|
||||||
(id, newValue) => {
|
|
||||||
const newPairs = keyValuePairs.map((pair) =>
|
|
||||||
pair.id === id ? { ...pair, value: newValue } : pair,
|
|
||||||
);
|
|
||||||
handleVisualChange(newPairs);
|
|
||||||
},
|
|
||||||
[keyValuePairs, handleVisualChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 填入模板
|
|
||||||
const fillTemplate = useCallback(() => {
|
|
||||||
if (template) {
|
|
||||||
const templateString = JSON.stringify(template, null, 2);
|
|
||||||
|
|
||||||
if (formApi && field) {
|
|
||||||
formApi.setValue(field, templateString);
|
|
||||||
}
|
|
||||||
|
|
||||||
setManualText(templateString);
|
|
||||||
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
|
|
||||||
onChange?.(templateString);
|
|
||||||
setJsonError('');
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
template,
|
|
||||||
onChange,
|
|
||||||
formApi,
|
|
||||||
field,
|
|
||||||
objectToKeyValueArray,
|
|
||||||
keyValuePairs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 渲染值输入控件(支持嵌套)
|
|
||||||
const renderValueInput = (pairId, pairKey, value) => {
|
|
||||||
const valueType = typeof value;
|
|
||||||
|
|
||||||
if (valueType === 'boolean') {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<Switch
|
|
||||||
checked={value}
|
|
||||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
|
||||||
/>
|
|
||||||
<Text type='tertiary' className='ml-2'>
|
|
||||||
{value ? t('true') : t('false')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueType === 'number') {
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder={t('输入数字')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueType === 'object' && value !== null) {
|
|
||||||
// 简化嵌套对象的处理,使用TextArea
|
|
||||||
return (
|
|
||||||
<TextArea
|
|
||||||
rows={2}
|
|
||||||
value={JSON.stringify(value, null, 2)}
|
|
||||||
onChange={(txt) => {
|
|
||||||
try {
|
|
||||||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
|
||||||
updateValue(pairId, obj);
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('输入JSON对象')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字符串或其他原始类型
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
placeholder={t('参数值')}
|
|
||||||
value={String(value)}
|
|
||||||
suffix={renderStringValueSuffix?.({ pairId, pairKey, value })}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
let convertedValue = newValue;
|
|
||||||
if (newValue === 'true') convertedValue = true;
|
|
||||||
else if (newValue === 'false') convertedValue = false;
|
|
||||||
else if (!isNaN(newValue) && newValue !== '') {
|
|
||||||
const num = Number(newValue);
|
|
||||||
// 检查是否为整数
|
|
||||||
if (Number.isInteger(num)) {
|
|
||||||
convertedValue = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateValue(pairId, convertedValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染键值对编辑器
|
|
||||||
const renderKeyValueEditor = () => {
|
|
||||||
return (
|
|
||||||
<div className='space-y-1'>
|
|
||||||
{/* 重复键警告 */}
|
|
||||||
{duplicateKeys.size > 0 && (
|
|
||||||
<Banner
|
|
||||||
type='warning'
|
|
||||||
icon={<IconAlertTriangle />}
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<Text strong>{t('存在重复的键名:')}</Text>
|
|
||||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
|
||||||
<br />
|
|
||||||
<Text type='tertiary' size='small'>
|
|
||||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='mb-3'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{keyValuePairs.length === 0 && (
|
|
||||||
<div className='text-center py-6 px-4'>
|
|
||||||
<Text type='tertiary' className='text-gray-500 text-sm'>
|
|
||||||
{t('暂无数据,点击下方按钮添加键值对')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{keyValuePairs.map((pair, index) => {
|
|
||||||
const isDuplicate = duplicateKeys.has(pair.key);
|
|
||||||
const isLastDuplicate =
|
|
||||||
isDuplicate &&
|
|
||||||
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row key={pair.id} gutter={8} align='middle'>
|
|
||||||
<Col span={10}>
|
|
||||||
<div className='relative'>
|
|
||||||
<Input
|
|
||||||
placeholder={t('键名')}
|
|
||||||
value={pair.key}
|
|
||||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
|
||||||
status={isDuplicate ? 'warning' : undefined}
|
|
||||||
/>
|
|
||||||
{isDuplicate && (
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
isLastDuplicate
|
|
||||||
? t('这是重复键中的最后一个,其值将被使用')
|
|
||||||
: t('重复的键名,此值将被后面的同名键覆盖')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconAlertTriangle
|
|
||||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
|
||||||
style={{
|
|
||||||
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
{renderValueInput(pair.id, pair.key, pair.value)}
|
|
||||||
</Col>
|
|
||||||
<Col span={2}>
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
type='danger'
|
|
||||||
theme='borderless'
|
|
||||||
onClick={() => removeKeyValue(pair.id)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className='mt-2 flex justify-center'>
|
|
||||||
<Button
|
|
||||||
icon={<IconPlus />}
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
onClick={addKeyValue}
|
|
||||||
>
|
|
||||||
{t('添加键值对')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
|
||||||
const renderRegionEditor = () => {
|
|
||||||
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
|
|
||||||
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{/* 重复键警告 */}
|
|
||||||
{duplicateKeys.size > 0 && (
|
|
||||||
<Banner
|
|
||||||
type='warning'
|
|
||||||
icon={<IconAlertTriangle />}
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<Text strong>{t('存在重复的键名:')}</Text>
|
|
||||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
|
||||||
<br />
|
|
||||||
<Text type='tertiary' size='small'>
|
|
||||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className='mb-3'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 默认区域 */}
|
|
||||||
<Form.Slot label={t('默认区域')}>
|
|
||||||
<Input
|
|
||||||
placeholder={t('默认区域,如: us-central1')}
|
|
||||||
value={defaultPair ? defaultPair.value : ''}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (defaultPair) {
|
|
||||||
updateValue(defaultPair.id, value);
|
|
||||||
} else {
|
|
||||||
const newPairs = [
|
|
||||||
...keyValuePairs,
|
|
||||||
{
|
|
||||||
id: generateUniqueId(),
|
|
||||||
key: 'default',
|
|
||||||
value: value,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
handleVisualChange(newPairs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Slot>
|
|
||||||
|
|
||||||
{/* 模型专用区域 */}
|
|
||||||
<Form.Slot label={t('模型专用区域')}>
|
|
||||||
<div>
|
|
||||||
{modelPairs.map((pair) => {
|
|
||||||
const isDuplicate = duplicateKeys.has(pair.key);
|
|
||||||
return (
|
|
||||||
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
|
|
||||||
<Col span={10}>
|
|
||||||
<div className='relative'>
|
|
||||||
<Input
|
|
||||||
placeholder={t('模型名称')}
|
|
||||||
value={pair.key}
|
|
||||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
|
||||||
status={isDuplicate ? 'warning' : undefined}
|
|
||||||
/>
|
|
||||||
{isDuplicate && (
|
|
||||||
<Tooltip content={t('重复的键名')}>
|
|
||||||
<IconAlertTriangle
|
|
||||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
|
||||||
style={{ color: '#faad14', fontSize: '14px' }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Input
|
|
||||||
placeholder={t('区域')}
|
|
||||||
value={pair.value}
|
|
||||||
onChange={(newValue) => updateValue(pair.id, newValue)}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={2}>
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
type='danger'
|
|
||||||
theme='borderless'
|
|
||||||
onClick={() => removeKeyValue(pair.id)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className='mt-2 flex justify-center'>
|
|
||||||
<Button
|
|
||||||
icon={<IconPlus />}
|
|
||||||
onClick={addKeyValue}
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
>
|
|
||||||
{t('添加模型区域')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form.Slot>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染可视化编辑器
|
|
||||||
const renderVisualEditor = () => {
|
|
||||||
switch (editorType) {
|
|
||||||
case 'region':
|
|
||||||
return renderRegionEditor();
|
|
||||||
case 'object':
|
|
||||||
case 'keyValue':
|
|
||||||
default:
|
|
||||||
return renderKeyValueEditor();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.Slot label={label}>
|
|
||||||
<Card
|
|
||||||
header={
|
|
||||||
<div className='flex justify-between items-center'>
|
|
||||||
<Tabs
|
|
||||||
type='slash'
|
|
||||||
activeKey={editMode}
|
|
||||||
onChange={(key) => {
|
|
||||||
if (key === 'manual' && editMode === 'visual') {
|
|
||||||
setEditMode('manual');
|
|
||||||
} else if (key === 'visual' && editMode === 'manual') {
|
|
||||||
toggleEditMode();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabPane tab={t('可视化')} itemKey='visual' />
|
|
||||||
<TabPane tab={t('手动编辑')} itemKey='manual' />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{template && templateLabel && (
|
|
||||||
<Button type='tertiary' onClick={fillTemplate} size='small'>
|
|
||||||
{templateLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
headerStyle={{ padding: '12px 16px' }}
|
|
||||||
bodyStyle={{ padding: '16px' }}
|
|
||||||
className='!rounded-2xl'
|
|
||||||
>
|
|
||||||
{/* JSON错误提示 */}
|
|
||||||
{hasJsonError && (
|
|
||||||
<Banner
|
|
||||||
type='danger'
|
|
||||||
description={`JSON 格式错误: ${jsonError}`}
|
|
||||||
className='mb-3'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 编辑器内容 */}
|
|
||||||
{editMode === 'visual' ? (
|
|
||||||
<div>
|
|
||||||
{renderVisualEditor()}
|
|
||||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
|
||||||
<Form.Input
|
|
||||||
field={field}
|
|
||||||
value={value}
|
|
||||||
rules={rules}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
noLabel={true}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<TextArea
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={manualText}
|
|
||||||
onChange={handleManualChange}
|
|
||||||
showClear={showClear}
|
|
||||||
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
|
|
||||||
/>
|
|
||||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
|
||||||
<Form.Input
|
|
||||||
field={field}
|
|
||||||
value={value}
|
|
||||||
rules={rules}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
noLabel={true}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 额外文本显示在卡片底部 */}
|
|
||||||
{extraText && (
|
|
||||||
<Divider margin='12px' align='center'>
|
|
||||||
<Text type='tertiary' size='small'>
|
|
||||||
{extraText}
|
|
||||||
</Text>
|
|
||||||
</Divider>
|
|
||||||
)}
|
|
||||||
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
|
|
||||||
</Card>
|
|
||||||
</Form.Slot>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default JSONEditor;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Spin } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const Loading = ({ size = 'small' }) => {
|
|
||||||
return (
|
|
||||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
|
|
||||||
<Spin size={size} spinning={true} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Loading;
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
// 通用渲染函数:限制项目数量显示,支持popover展开
|
|
||||||
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
|
|
||||||
if (!items || items.length === 0) return '-';
|
|
||||||
const displayItems = items.slice(0, maxDisplay);
|
|
||||||
const remainingItems = items.slice(maxDisplay);
|
|
||||||
return (
|
|
||||||
<Space spacing={1} wrap>
|
|
||||||
{displayItems.map((item, idx) => renderItem(item, idx))}
|
|
||||||
{remainingItems.length > 0 && (
|
|
||||||
<Popover
|
|
||||||
content={
|
|
||||||
<div className='p-2'>
|
|
||||||
<Space spacing={1} wrap>
|
|
||||||
{remainingItems.map((item, idx) => renderItem(item, idx))}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
position='top'
|
|
||||||
>
|
|
||||||
<Tag size='small' shape='circle' color='grey'>
|
|
||||||
+{remainingItems.length}
|
|
||||||
</Tag>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染描述字段,长文本支持tooltip
|
|
||||||
export const renderDescription = (text, maxWidth = 200) => {
|
|
||||||
return (
|
|
||||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
|
|
||||||
{text || '-'}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, {
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useImperativeHandle,
|
|
||||||
forwardRef,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ScrollableContainer 可滚动容器组件
|
|
||||||
*
|
|
||||||
* 提供自动检测滚动状态和显示渐变指示器的功能
|
|
||||||
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const ScrollableContainer = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
children,
|
|
||||||
maxHeight = '24rem',
|
|
||||||
className = '',
|
|
||||||
contentClassName = '',
|
|
||||||
fadeIndicatorClassName = '',
|
|
||||||
checkInterval = 100,
|
|
||||||
scrollThreshold = 5,
|
|
||||||
debounceDelay = 16, // ~60fps
|
|
||||||
onScroll,
|
|
||||||
onScrollStateChange,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const debounceTimerRef = useRef(null);
|
|
||||||
const resizeObserverRef = useRef(null);
|
|
||||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
|
||||||
const onScrollRef = useRef(onScroll);
|
|
||||||
|
|
||||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onScrollStateChangeRef.current = onScrollStateChange;
|
|
||||||
}, [onScrollStateChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onScrollRef.current = onScroll;
|
|
||||||
}, [onScroll]);
|
|
||||||
|
|
||||||
const debounce = useCallback((func, delay) => {
|
|
||||||
return (...args) => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
}
|
|
||||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkScrollable = useCallback(() => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const element = scrollRef.current;
|
|
||||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
|
||||||
const isAtBottom =
|
|
||||||
element.scrollTop + element.clientHeight >=
|
|
||||||
element.scrollHeight - scrollThreshold;
|
|
||||||
const shouldShowHint = isScrollable && !isAtBottom;
|
|
||||||
|
|
||||||
setShowScrollHint(shouldShowHint);
|
|
||||||
|
|
||||||
if (onScrollStateChangeRef.current) {
|
|
||||||
onScrollStateChangeRef.current({
|
|
||||||
isScrollable,
|
|
||||||
isAtBottom,
|
|
||||||
showScrollHint: shouldShowHint,
|
|
||||||
scrollTop: element.scrollTop,
|
|
||||||
scrollHeight: element.scrollHeight,
|
|
||||||
clientHeight: element.clientHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [scrollThreshold]);
|
|
||||||
|
|
||||||
const debouncedCheckScrollable = useMemo(
|
|
||||||
() => debounce(checkScrollable, debounceDelay),
|
|
||||||
[debounce, checkScrollable, debounceDelay],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
|
||||||
(e) => {
|
|
||||||
debouncedCheckScrollable();
|
|
||||||
if (onScrollRef.current) {
|
|
||||||
onScrollRef.current(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[debouncedCheckScrollable],
|
|
||||||
);
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
checkScrollable: () => {
|
|
||||||
checkScrollable();
|
|
||||||
},
|
|
||||||
scrollToTop: () => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollToBottom: () => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getScrollInfo: () => {
|
|
||||||
if (!scrollRef.current) return null;
|
|
||||||
const element = scrollRef.current;
|
|
||||||
return {
|
|
||||||
scrollTop: element.scrollTop,
|
|
||||||
scrollHeight: element.scrollHeight,
|
|
||||||
clientHeight: element.clientHeight,
|
|
||||||
isScrollable: element.scrollHeight > element.clientHeight,
|
|
||||||
isAtBottom:
|
|
||||||
element.scrollTop + element.clientHeight >=
|
|
||||||
element.scrollHeight - scrollThreshold,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[checkScrollable, scrollThreshold],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
checkScrollable();
|
|
||||||
}, checkInterval);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [checkScrollable, checkInterval]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
if (typeof MutationObserver !== 'undefined') {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
debouncedCheckScrollable();
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(scrollRef.current, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
debouncedCheckScrollable();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserverRef.current.observe(scrollRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (resizeObserverRef.current) {
|
|
||||||
resizeObserverRef.current.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [debouncedCheckScrollable]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
maxHeight,
|
|
||||||
}),
|
|
||||||
[maxHeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fadeIndicatorStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
opacity: showScrollHint ? 1 : 0,
|
|
||||||
}),
|
|
||||||
[showScrollHint],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`card-content-container ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
|
||||||
style={containerStyle}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
|
||||||
style={fadeIndicatorStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ScrollableContainer.displayName = 'ScrollableContainer';
|
|
||||||
|
|
||||||
export default ScrollableContainer;
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
|
||||||
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
|
|
||||||
import {
|
|
||||||
Divider,
|
|
||||||
Button,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Collapsible,
|
|
||||||
Checkbox,
|
|
||||||
Skeleton,
|
|
||||||
Tooltip,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用可选择按钮组组件
|
|
||||||
*
|
|
||||||
* @param {string} title 标题
|
|
||||||
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
|
|
||||||
* @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
|
|
||||||
* @param {(value:any)=>void} onChange 选择改变回调
|
|
||||||
* @param {function} t i18n
|
|
||||||
* @param {object} style 额外样式
|
|
||||||
* @param {boolean} collapsible 是否支持折叠,默认true
|
|
||||||
* @param {number} collapseHeight 折叠时的高度,默认200
|
|
||||||
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
|
|
||||||
* @param {boolean} loading 是否处于加载状态
|
|
||||||
* @param {string} variant 颜色变体: 'violet' | 'teal' | 'amber' | 'rose' | 'green',不传则使用默认蓝色
|
|
||||||
*/
|
|
||||||
const SelectableButtonGroup = ({
|
|
||||||
title,
|
|
||||||
items = [],
|
|
||||||
activeValue,
|
|
||||||
onChange,
|
|
||||||
t = (v) => v,
|
|
||||||
style = {},
|
|
||||||
collapsible = true,
|
|
||||||
collapseHeight = 200,
|
|
||||||
withCheckbox = false,
|
|
||||||
loading = false,
|
|
||||||
variant,
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [skeletonCount] = useState(12);
|
|
||||||
const [containerRef, containerWidth] = useContainerWidth();
|
|
||||||
|
|
||||||
const ConditionalTooltipText = ({ text }) => {
|
|
||||||
const textRef = useRef(null);
|
|
||||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = textRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
setIsOverflowing(el.scrollWidth > el.clientWidth);
|
|
||||||
}, [text, containerWidth]);
|
|
||||||
|
|
||||||
const textElement = (
|
|
||||||
<span ref={textRef} className='sbg-ellipsis'>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return isOverflowing ? (
|
|
||||||
<Tooltip content={text}>{textElement}</Tooltip>
|
|
||||||
) : (
|
|
||||||
textElement
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 基于容器宽度计算响应式列数和标签显示策略
|
|
||||||
const getResponsiveConfig = () => {
|
|
||||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
|
||||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
|
||||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
|
||||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
|
||||||
};
|
|
||||||
|
|
||||||
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
|
|
||||||
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
|
|
||||||
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
|
|
||||||
const showSkeleton = useMinimumLoadingTime(loading);
|
|
||||||
|
|
||||||
// 统一使用紧凑的网格间距
|
|
||||||
const gutterSize = [4, 4];
|
|
||||||
|
|
||||||
// 计算 Semi UI Col 的 span 值
|
|
||||||
const getColSpan = () => {
|
|
||||||
return Math.floor(24 / perRow);
|
|
||||||
};
|
|
||||||
|
|
||||||
const maskStyle = isOpen
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
WebkitMaskImage:
|
|
||||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkStyle = {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: 'center',
|
|
||||||
bottom: -10,
|
|
||||||
fontWeight: 400,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSkeletonButtons = () => {
|
|
||||||
const placeholder = (
|
|
||||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
|
||||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
|
||||||
<Col span={getColSpan()} key={index}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: 'var(--semi-border-radius-medium)',
|
|
||||||
padding: '0 12px',
|
|
||||||
gap: '6px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{withCheckbox && (
|
|
||||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
|
||||||
)}
|
|
||||||
<Skeleton.Title
|
|
||||||
active
|
|
||||||
style={{
|
|
||||||
width: `${60 + (index % 3) * 20}px`,
|
|
||||||
height: 14,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const contentElement = showSkeleton ? (
|
|
||||||
renderSkeletonButtons()
|
|
||||||
) : (
|
|
||||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = Array.isArray(activeValue)
|
|
||||||
? activeValue.includes(item.value)
|
|
||||||
: activeValue === item.value;
|
|
||||||
|
|
||||||
if (withCheckbox) {
|
|
||||||
return (
|
|
||||||
<Col span={getColSpan()} key={item.value}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
/* disabled */
|
|
||||||
}}
|
|
||||||
theme={isActive ? 'light' : 'outline'}
|
|
||||||
type={isActive ? 'primary' : 'tertiary'}
|
|
||||||
className='sbg-button'
|
|
||||||
icon={
|
|
||||||
<Checkbox
|
|
||||||
checked={isActive}
|
|
||||||
onChange={() => onChange(item.value)}
|
|
||||||
style={{ pointerEvents: 'auto' }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={{ width: '100%', cursor: 'default' }}
|
|
||||||
>
|
|
||||||
<div className='sbg-content'>
|
|
||||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
|
||||||
<ConditionalTooltipText text={item.label} />
|
|
||||||
{item.tagCount !== undefined && shouldShowTags && (
|
|
||||||
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
|
|
||||||
{item.tagCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col span={getColSpan()} key={item.value}>
|
|
||||||
<Button
|
|
||||||
onClick={() => onChange(item.value)}
|
|
||||||
theme={isActive ? 'light' : 'outline'}
|
|
||||||
type={isActive ? 'primary' : 'tertiary'}
|
|
||||||
className='sbg-button'
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
<div className='sbg-content'>
|
|
||||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
|
||||||
<ConditionalTooltipText text={item.label} />
|
|
||||||
{item.tagCount !== undefined && shouldShowTags && item.tagCount !== '' && (
|
|
||||||
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
|
|
||||||
{item.tagCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}${variant ? ` sbg-variant-${variant}` : ''}`}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
{title && (
|
|
||||||
<Divider margin='12px' align='left'>
|
|
||||||
{showSkeleton ? (
|
|
||||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</Divider>
|
|
||||||
)}
|
|
||||||
{needCollapse && !showSkeleton ? (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<Collapsible
|
|
||||||
isOpen={isOpen}
|
|
||||||
collapseHeight={collapseHeight}
|
|
||||||
style={{ ...maskStyle }}
|
|
||||||
>
|
|
||||||
{contentElement}
|
|
||||||
</Collapsible>
|
|
||||||
{isOpen ? null : (
|
|
||||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
|
||||||
<IconChevronDown size='small' />
|
|
||||||
<span>{t('展开更多')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
onClick={toggle}
|
|
||||||
style={{
|
|
||||||
...linkStyle,
|
|
||||||
position: 'static',
|
|
||||||
marginTop: 8,
|
|
||||||
bottom: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconChevronUp size='small' />
|
|
||||||
<span>{t('收起')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
contentElement
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SelectableButtonGroup;
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
|
|
||||||
import { Bell } from 'lucide-react';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
|
||||||
|
|
||||||
const AnnouncementsPanel = ({
|
|
||||||
announcementData,
|
|
||||||
announcementLegendData,
|
|
||||||
CARD_PROPS,
|
|
||||||
ILLUSTRATION_SIZE,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className='shadow-sm !rounded-2xl lg:col-span-2'
|
|
||||||
title={
|
|
||||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Bell size={16} />
|
|
||||||
{t('系统公告')}
|
|
||||||
<Tag color='white' shape='circle'>
|
|
||||||
{t('显示最新20条')}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
{/* 图例 */}
|
|
||||||
<div className='flex flex-wrap gap-3 text-xs'>
|
|
||||||
{announcementLegendData.map((legend, index) => (
|
|
||||||
<div key={index} className='flex items-center gap-1'>
|
|
||||||
<div
|
|
||||||
className='w-2 h-2 rounded-full'
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
legend.color === 'grey'
|
|
||||||
? '#8b9aa7'
|
|
||||||
: legend.color === 'blue'
|
|
||||||
? '#3b82f6'
|
|
||||||
: legend.color === 'green'
|
|
||||||
? '#10b981'
|
|
||||||
: legend.color === 'orange'
|
|
||||||
? '#f59e0b'
|
|
||||||
: legend.color === 'red'
|
|
||||||
? '#ef4444'
|
|
||||||
: '#8b9aa7',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className='text-gray-600'>{legend.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<ScrollableContainer maxHeight='24rem'>
|
|
||||||
{announcementData.length > 0 ? (
|
|
||||||
<Timeline mode='left'>
|
|
||||||
{announcementData.map((item, idx) => {
|
|
||||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={idx}
|
|
||||||
type={item.type || 'default'}
|
|
||||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
|
||||||
extra={
|
|
||||||
item.extra ? (
|
|
||||||
<div
|
|
||||||
className='text-xs text-gray-500'
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: marked.parse(item.content || ''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Timeline.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
) : (
|
|
||||||
<div className='flex justify-center items-center py-8'>
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
|
||||||
}
|
|
||||||
title={t('暂无系统公告')}
|
|
||||||
description={t('请联系管理员在系统设置中配置公告信息')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollableContainer>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnnouncementsPanel;
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
|
||||||
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
|
||||||
|
|
||||||
const ApiInfoPanel = ({
|
|
||||||
apiInfoData,
|
|
||||||
handleCopyUrl,
|
|
||||||
handleSpeedTest,
|
|
||||||
CARD_PROPS,
|
|
||||||
FLEX_CENTER_GAP2,
|
|
||||||
ILLUSTRATION_SIZE,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className='bg-gray-50 border-0 !rounded-2xl'
|
|
||||||
title={
|
|
||||||
<div className={FLEX_CENTER_GAP2}>
|
|
||||||
<Server size={16} />
|
|
||||||
{t('API信息')}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<ScrollableContainer maxHeight='24rem'>
|
|
||||||
{apiInfoData.length > 0 ? (
|
|
||||||
apiInfoData.map((api) => (
|
|
||||||
<React.Fragment key={api.id}>
|
|
||||||
<div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
|
|
||||||
<div className='flex-shrink-0 mr-3'>
|
|
||||||
<Avatar size='extra-small' color={api.color}>
|
|
||||||
{api.route.substring(0, 2)}
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<div className='flex flex-wrap items-center justify-between mb-1 w-full gap-2'>
|
|
||||||
<span className='text-sm font-medium text-gray-900 !font-bold break-all'>
|
|
||||||
{api.route}
|
|
||||||
</span>
|
|
||||||
<div className='flex items-center gap-1 mt-1 lg:mt-0'>
|
|
||||||
<Tag
|
|
||||||
prefixIcon={<Gauge size={12} />}
|
|
||||||
size='small'
|
|
||||||
color='white'
|
|
||||||
shape='circle'
|
|
||||||
onClick={() => handleSpeedTest(api.url)}
|
|
||||||
className='cursor-pointer hover:opacity-80 text-xs'
|
|
||||||
>
|
|
||||||
{t('测速')}
|
|
||||||
</Tag>
|
|
||||||
<Tag
|
|
||||||
prefixIcon={<ExternalLink size={12} />}
|
|
||||||
size='small'
|
|
||||||
color='white'
|
|
||||||
shape='circle'
|
|
||||||
onClick={() =>
|
|
||||||
window.open(api.url, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
|
||||||
className='cursor-pointer hover:opacity-80 text-xs'
|
|
||||||
>
|
|
||||||
{t('跳转')}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-1 mb-1'>
|
|
||||||
<span
|
|
||||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
|
|
||||||
onClick={() => handleCopyUrl(api.url)}
|
|
||||||
>
|
|
||||||
{api.url}
|
|
||||||
</span>
|
|
||||||
<Copy
|
|
||||||
size={14}
|
|
||||||
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
|
|
||||||
onClick={() => handleCopyUrl(api.url)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='text-gray-500'>{api.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className='flex justify-center items-center min-h-[20rem] w-full'>
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
|
||||||
}
|
|
||||||
title={t('暂无API信息')}
|
|
||||||
description={t('请联系管理员在系统设置中配置API信息')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollableContainer>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ApiInfoPanel;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
|
||||||
import { PieChart } from 'lucide-react';
|
|
||||||
import { VChart } from '@visactor/react-vchart';
|
|
||||||
|
|
||||||
const ChartsPanel = ({
|
|
||||||
activeChartTab,
|
|
||||||
setActiveChartTab,
|
|
||||||
spec_line,
|
|
||||||
spec_model_line,
|
|
||||||
spec_pie,
|
|
||||||
spec_rank_bar,
|
|
||||||
spec_user_rank,
|
|
||||||
spec_user_trend,
|
|
||||||
isAdminUser,
|
|
||||||
CARD_PROPS,
|
|
||||||
CHART_CONFIG,
|
|
||||||
FLEX_CENTER_GAP2,
|
|
||||||
hasApiInfoPanel,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
|
||||||
title={
|
|
||||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3'>
|
|
||||||
<div className={FLEX_CENTER_GAP2}>
|
|
||||||
<PieChart size={16} />
|
|
||||||
{t('模型数据分析')}
|
|
||||||
</div>
|
|
||||||
<Tabs
|
|
||||||
type='slash'
|
|
||||||
activeKey={activeChartTab}
|
|
||||||
onChange={setActiveChartTab}
|
|
||||||
>
|
|
||||||
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
|
||||||
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
|
|
||||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
|
||||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
|
||||||
{isAdminUser && (
|
|
||||||
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
|
|
||||||
)}
|
|
||||||
{isAdminUser && (
|
|
||||||
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<div className='h-96 p-2'>
|
|
||||||
{activeChartTab === '1' && (
|
|
||||||
<VChart spec={spec_line} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
{activeChartTab === '2' && (
|
|
||||||
<VChart spec={spec_model_line} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
{activeChartTab === '3' && (
|
|
||||||
<VChart spec={spec_pie} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
{activeChartTab === '4' && (
|
|
||||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
{activeChartTab === '5' && isAdminUser && (
|
|
||||||
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
{activeChartTab === '6' && isAdminUser && (
|
|
||||||
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChartsPanel;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
|
||||||
import { RefreshCw, Search } from 'lucide-react';
|
|
||||||
|
|
||||||
const DashboardHeader = ({
|
|
||||||
getGreeting,
|
|
||||||
greetingVisible,
|
|
||||||
showSearchModal,
|
|
||||||
refresh,
|
|
||||||
loading,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex items-center justify-between mb-4'>
|
|
||||||
<h2
|
|
||||||
className='text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out'
|
|
||||||
style={{ opacity: greetingVisible ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{getGreeting}
|
|
||||||
</h2>
|
|
||||||
<div className='flex gap-3'>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
icon={<Search size={16} />}
|
|
||||||
onClick={showSearchModal}
|
|
||||||
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
icon={<RefreshCw size={16} />}
|
|
||||||
onClick={refresh}
|
|
||||||
loading={loading}
|
|
||||||
className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardHeader;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
|
|
||||||
import { HelpCircle } from 'lucide-react';
|
|
||||||
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
|
||||||
|
|
||||||
const FaqPanel = ({
|
|
||||||
faqData,
|
|
||||||
CARD_PROPS,
|
|
||||||
FLEX_CENTER_GAP2,
|
|
||||||
ILLUSTRATION_SIZE,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
|
||||||
title={
|
|
||||||
<div className={FLEX_CENTER_GAP2}>
|
|
||||||
<HelpCircle size={16} />
|
|
||||||
{t('常见问答')}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
<ScrollableContainer maxHeight='24rem'>
|
|
||||||
{faqData.length > 0 ? (
|
|
||||||
<Collapse
|
|
||||||
accordion
|
|
||||||
expandIcon={<IconPlus />}
|
|
||||||
collapseIcon={<IconMinus />}
|
|
||||||
>
|
|
||||||
{faqData.map((item, index) => (
|
|
||||||
<Collapse.Panel
|
|
||||||
key={index}
|
|
||||||
header={item.question}
|
|
||||||
itemKey={index.toString()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: marked.parse(item.answer || ''),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Collapse.Panel>
|
|
||||||
))}
|
|
||||||
</Collapse>
|
|
||||||
) : (
|
|
||||||
<div className='flex justify-center items-center py-8'>
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
|
||||||
}
|
|
||||||
title={t('暂无常见问答')}
|
|
||||||
description={t('请联系管理员在系统设置中配置常见问答')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollableContainer>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FaqPanel;
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Avatar, Skeleton, Tag } from '@douyinfe/semi-ui';
|
|
||||||
import { VChart } from '@visactor/react-vchart';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const StatsCards = ({
|
|
||||||
groupedStatsData,
|
|
||||||
loading,
|
|
||||||
getTrendSpec,
|
|
||||||
CARD_PROPS,
|
|
||||||
CHART_CONFIG,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className='mb-4'>
|
|
||||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
|
||||||
{groupedStatsData.map((group, idx) => (
|
|
||||||
<Card
|
|
||||||
key={idx}
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
|
||||||
title={group.title}
|
|
||||||
>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{group.items.map((item, itemIdx) => (
|
|
||||||
<div
|
|
||||||
key={itemIdx}
|
|
||||||
className='flex items-center justify-between cursor-pointer'
|
|
||||||
onClick={item.onClick}
|
|
||||||
>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<Avatar
|
|
||||||
className='mr-3'
|
|
||||||
size='small'
|
|
||||||
color={item.avatarColor}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div className='text-xs text-gray-500'>{item.title}</div>
|
|
||||||
<div className='text-lg font-semibold'>
|
|
||||||
<Skeleton
|
|
||||||
loading={loading}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Paragraph
|
|
||||||
active
|
|
||||||
rows={1}
|
|
||||||
style={{
|
|
||||||
width: '65px',
|
|
||||||
height: '24px',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{item.title === t('当前余额') ? (
|
|
||||||
<Tag
|
|
||||||
color='white'
|
|
||||||
shape='circle'
|
|
||||||
size='large'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate('/console/topup');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('充值')}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
(loading ||
|
|
||||||
(item.trendData && item.trendData.length > 0)) && (
|
|
||||||
<div className='w-24 h-10'>
|
|
||||||
<VChart
|
|
||||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
|
||||||
option={CHART_CONFIG}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatsCards;
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Button,
|
|
||||||
Spin,
|
|
||||||
Tabs,
|
|
||||||
TabPane,
|
|
||||||
Tag,
|
|
||||||
Empty,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { Gauge, RefreshCw } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
|
||||||
|
|
||||||
const UptimePanel = ({
|
|
||||||
uptimeData,
|
|
||||||
uptimeLoading,
|
|
||||||
activeUptimeTab,
|
|
||||||
setActiveUptimeTab,
|
|
||||||
loadUptimeData,
|
|
||||||
uptimeLegendData,
|
|
||||||
renderMonitorList,
|
|
||||||
CARD_PROPS,
|
|
||||||
ILLUSTRATION_SIZE,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
{...CARD_PROPS}
|
|
||||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
|
||||||
title={
|
|
||||||
<div className='flex items-center justify-between w-full gap-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Gauge size={16} />
|
|
||||||
{t('服务可用性')}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
icon={<RefreshCw size={14} />}
|
|
||||||
onClick={loadUptimeData}
|
|
||||||
loading={uptimeLoading}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className='relative'>
|
|
||||||
<Spin spinning={uptimeLoading}>
|
|
||||||
{uptimeData.length > 0 ? (
|
|
||||||
uptimeData.length === 1 ? (
|
|
||||||
<ScrollableContainer maxHeight='24rem'>
|
|
||||||
{renderMonitorList(uptimeData[0].monitors)}
|
|
||||||
</ScrollableContainer>
|
|
||||||
) : (
|
|
||||||
<Tabs
|
|
||||||
type='card'
|
|
||||||
collapsible
|
|
||||||
activeKey={activeUptimeTab}
|
|
||||||
onChange={setActiveUptimeTab}
|
|
||||||
size='small'
|
|
||||||
>
|
|
||||||
{uptimeData.map((group, groupIdx) => (
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span className='flex items-center gap-2'>
|
|
||||||
<Gauge size={14} />
|
|
||||||
{group.categoryName}
|
|
||||||
<Tag
|
|
||||||
color={
|
|
||||||
activeUptimeTab === group.categoryName
|
|
||||||
? 'red'
|
|
||||||
: 'grey'
|
|
||||||
}
|
|
||||||
size='small'
|
|
||||||
shape='circle'
|
|
||||||
>
|
|
||||||
{group.monitors ? group.monitors.length : 0}
|
|
||||||
</Tag>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey={group.categoryName}
|
|
||||||
key={groupIdx}
|
|
||||||
>
|
|
||||||
<ScrollableContainer maxHeight='21.5rem'>
|
|
||||||
{renderMonitorList(group.monitors)}
|
|
||||||
</ScrollableContainer>
|
|
||||||
</TabPane>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className='flex justify-center items-center py-8'>
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
|
||||||
}
|
|
||||||
title={t('暂无监控数据')}
|
|
||||||
description={t('请联系管理员在系统设置中配置Uptime')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Spin>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图例 */}
|
|
||||||
{uptimeData.length > 0 && (
|
|
||||||
<div className='p-3 bg-gray-50 rounded-b-2xl'>
|
|
||||||
<div className='flex flex-wrap gap-3 text-xs justify-center'>
|
|
||||||
{uptimeLegendData.map((legend, index) => (
|
|
||||||
<div key={index} className='flex items-center gap-1'>
|
|
||||||
<div
|
|
||||||
className='w-2 h-2 rounded-full'
|
|
||||||
style={{ backgroundColor: legend.color }}
|
|
||||||
/>
|
|
||||||
<span className='text-gray-600'>{legend.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UptimePanel;
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext, useEffect } from 'react';
|
|
||||||
import { getRelativeTime } from '../../helpers';
|
|
||||||
import { UserContext } from '../../context/User';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
|
|
||||||
import DashboardHeader from './DashboardHeader';
|
|
||||||
import StatsCards from './StatsCards';
|
|
||||||
import ChartsPanel from './ChartsPanel';
|
|
||||||
import ApiInfoPanel from './ApiInfoPanel';
|
|
||||||
import AnnouncementsPanel from './AnnouncementsPanel';
|
|
||||||
import FaqPanel from './FaqPanel';
|
|
||||||
import UptimePanel from './UptimePanel';
|
|
||||||
import SearchModal from './modals/SearchModal';
|
|
||||||
|
|
||||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
|
||||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
|
||||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CHART_CONFIG,
|
|
||||||
CARD_PROPS,
|
|
||||||
FLEX_CENTER_GAP2,
|
|
||||||
ILLUSTRATION_SIZE,
|
|
||||||
ANNOUNCEMENT_LEGEND_DATA,
|
|
||||||
UPTIME_STATUS_MAP,
|
|
||||||
} from '../../constants/dashboard.constants';
|
|
||||||
import {
|
|
||||||
getTrendSpec,
|
|
||||||
handleCopyUrl,
|
|
||||||
handleSpeedTest,
|
|
||||||
getUptimeStatusColor,
|
|
||||||
getUptimeStatusText,
|
|
||||||
renderMonitorList,
|
|
||||||
} from '../../helpers/dashboard';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
|
||||||
// ========== Context ==========
|
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
|
||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
|
||||||
|
|
||||||
// ========== 主要数据管理 ==========
|
|
||||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
|
||||||
|
|
||||||
// ========== 图表管理 ==========
|
|
||||||
const dashboardCharts = useDashboardCharts(
|
|
||||||
dashboardData.dataExportDefaultTime,
|
|
||||||
dashboardData.setTrendData,
|
|
||||||
dashboardData.setConsumeQuota,
|
|
||||||
dashboardData.setTimes,
|
|
||||||
dashboardData.setConsumeTokens,
|
|
||||||
dashboardData.setPieData,
|
|
||||||
dashboardData.setLineData,
|
|
||||||
dashboardData.setModelColors,
|
|
||||||
dashboardData.t,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== 统计数据 ==========
|
|
||||||
const { groupedStatsData } = useDashboardStats(
|
|
||||||
userState,
|
|
||||||
dashboardData.consumeQuota,
|
|
||||||
dashboardData.consumeTokens,
|
|
||||||
dashboardData.times,
|
|
||||||
dashboardData.trendData,
|
|
||||||
dashboardData.performanceMetrics,
|
|
||||||
dashboardData.navigate,
|
|
||||||
dashboardData.t,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== 数据处理 ==========
|
|
||||||
const loadUserData = async () => {
|
|
||||||
if (dashboardData.isAdminUser) {
|
|
||||||
const userData = await dashboardData.loadUserQuotaData();
|
|
||||||
if (userData && userData.length > 0) {
|
|
||||||
dashboardCharts.updateUserChartData(userData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initChart = async () => {
|
|
||||||
await dashboardData.loadQuotaData().then((data) => {
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
dashboardCharts.updateChartData(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await loadUserData();
|
|
||||||
await dashboardData.loadUptimeData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
const data = await dashboardData.refresh();
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
dashboardCharts.updateChartData(data);
|
|
||||||
}
|
|
||||||
await loadUserData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchConfirm = async () => {
|
|
||||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
|
||||||
await loadUserData();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== 数据准备 ==========
|
|
||||||
const apiInfoData = statusState?.status?.api_info || [];
|
|
||||||
const announcementData = (statusState?.status?.announcements || []).map(
|
|
||||||
(item) => {
|
|
||||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
|
||||||
const absoluteTime =
|
|
||||||
pubDate && !isNaN(pubDate.getTime())
|
|
||||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
|
||||||
: item?.publishDate || '';
|
|
||||||
const relativeTime = getRelativeTime(item.publishDate);
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
time: absoluteTime,
|
|
||||||
relative: relativeTime,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const faqData = statusState?.status?.faq || [];
|
|
||||||
|
|
||||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
|
|
||||||
([status, info]) => ({
|
|
||||||
status: Number(status),
|
|
||||||
color: info.color,
|
|
||||||
label: dashboardData.t(info.label),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== Effects ==========
|
|
||||||
useEffect(() => {
|
|
||||||
initChart();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='h-full'>
|
|
||||||
<DashboardHeader
|
|
||||||
getGreeting={dashboardData.getGreeting}
|
|
||||||
greetingVisible={dashboardData.greetingVisible}
|
|
||||||
showSearchModal={dashboardData.showSearchModal}
|
|
||||||
refresh={handleRefresh}
|
|
||||||
loading={dashboardData.loading}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SearchModal
|
|
||||||
searchModalVisible={dashboardData.searchModalVisible}
|
|
||||||
handleSearchConfirm={handleSearchConfirm}
|
|
||||||
handleCloseModal={dashboardData.handleCloseModal}
|
|
||||||
isMobile={dashboardData.isMobile}
|
|
||||||
isAdminUser={dashboardData.isAdminUser}
|
|
||||||
inputs={dashboardData.inputs}
|
|
||||||
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
|
||||||
timeOptions={dashboardData.timeOptions}
|
|
||||||
handleInputChange={dashboardData.handleInputChange}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCards
|
|
||||||
groupedStatsData={groupedStatsData}
|
|
||||||
loading={dashboardData.loading}
|
|
||||||
getTrendSpec={getTrendSpec}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
CHART_CONFIG={CHART_CONFIG}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* API信息和图表面板 */}
|
|
||||||
<div className='mb-4'>
|
|
||||||
<div
|
|
||||||
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
|
|
||||||
>
|
|
||||||
<ChartsPanel
|
|
||||||
activeChartTab={dashboardData.activeChartTab}
|
|
||||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
|
||||||
spec_line={dashboardCharts.spec_line}
|
|
||||||
spec_model_line={dashboardCharts.spec_model_line}
|
|
||||||
spec_pie={dashboardCharts.spec_pie}
|
|
||||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
|
||||||
spec_user_rank={dashboardCharts.spec_user_rank}
|
|
||||||
spec_user_trend={dashboardCharts.spec_user_trend}
|
|
||||||
isAdminUser={dashboardData.isAdminUser}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
CHART_CONFIG={CHART_CONFIG}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dashboardData.hasApiInfoPanel && (
|
|
||||||
<ApiInfoPanel
|
|
||||||
apiInfoData={apiInfoData}
|
|
||||||
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
|
||||||
handleSpeedTest={handleSpeedTest}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 系统公告和常见问答卡片 */}
|
|
||||||
{dashboardData.hasInfoPanels && (
|
|
||||||
<div className='mb-4'>
|
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
|
|
||||||
{/* 公告卡片 */}
|
|
||||||
{dashboardData.announcementsEnabled && (
|
|
||||||
<AnnouncementsPanel
|
|
||||||
announcementData={announcementData}
|
|
||||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
|
|
||||||
(item) => ({
|
|
||||||
...item,
|
|
||||||
label: dashboardData.t(item.label),
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 常见问答卡片 */}
|
|
||||||
{dashboardData.faqEnabled && (
|
|
||||||
<FaqPanel
|
|
||||||
faqData={faqData}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 服务可用性卡片 */}
|
|
||||||
{dashboardData.uptimeEnabled && (
|
|
||||||
<UptimePanel
|
|
||||||
uptimeData={dashboardData.uptimeData}
|
|
||||||
uptimeLoading={dashboardData.uptimeLoading}
|
|
||||||
activeUptimeTab={dashboardData.activeUptimeTab}
|
|
||||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
|
||||||
loadUptimeData={dashboardData.loadUptimeData}
|
|
||||||
uptimeLegendData={uptimeLegendData}
|
|
||||||
renderMonitorList={(monitors) =>
|
|
||||||
renderMonitorList(
|
|
||||||
monitors,
|
|
||||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
|
||||||
(status) =>
|
|
||||||
getUptimeStatusText(
|
|
||||||
status,
|
|
||||||
UPTIME_STATUS_MAP,
|
|
||||||
dashboardData.t,
|
|
||||||
),
|
|
||||||
dashboardData.t,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import { Modal, Form } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const SearchModal = ({
|
|
||||||
searchModalVisible,
|
|
||||||
handleSearchConfirm,
|
|
||||||
handleCloseModal,
|
|
||||||
isMobile,
|
|
||||||
isAdminUser,
|
|
||||||
inputs,
|
|
||||||
dataExportDefaultTime,
|
|
||||||
timeOptions,
|
|
||||||
handleInputChange,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
const formRef = useRef();
|
|
||||||
|
|
||||||
const FORM_FIELD_PROPS = {
|
|
||||||
className: 'w-full mb-2 !rounded-lg',
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFormField = (Component, props) => (
|
|
||||||
<Component {...FORM_FIELD_PROPS} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const { start_timestamp, end_timestamp, username } = inputs;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={t('搜索条件')}
|
|
||||||
visible={searchModalVisible}
|
|
||||||
onOk={handleSearchConfirm}
|
|
||||||
onCancel={handleCloseModal}
|
|
||||||
closeOnEsc={true}
|
|
||||||
size={isMobile ? 'full-width' : 'small'}
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Form ref={formRef} layout='vertical' className='w-full'>
|
|
||||||
{createFormField(Form.DatePicker, {
|
|
||||||
field: 'start_timestamp',
|
|
||||||
label: t('起始时间'),
|
|
||||||
initValue: start_timestamp,
|
|
||||||
value: start_timestamp,
|
|
||||||
type: 'dateTime',
|
|
||||||
name: 'start_timestamp',
|
|
||||||
onChange: (value) => handleInputChange(value, 'start_timestamp'),
|
|
||||||
})}
|
|
||||||
|
|
||||||
{createFormField(Form.DatePicker, {
|
|
||||||
field: 'end_timestamp',
|
|
||||||
label: t('结束时间'),
|
|
||||||
initValue: end_timestamp,
|
|
||||||
value: end_timestamp,
|
|
||||||
type: 'dateTime',
|
|
||||||
name: 'end_timestamp',
|
|
||||||
onChange: (value) => handleInputChange(value, 'end_timestamp'),
|
|
||||||
})}
|
|
||||||
|
|
||||||
{createFormField(Form.Select, {
|
|
||||||
field: 'data_export_default_time',
|
|
||||||
label: t('时间粒度'),
|
|
||||||
initValue: dataExportDefaultTime,
|
|
||||||
placeholder: t('时间粒度'),
|
|
||||||
name: 'data_export_default_time',
|
|
||||||
optionList: timeOptions,
|
|
||||||
onChange: (value) =>
|
|
||||||
handleInputChange(value, 'data_export_default_time'),
|
|
||||||
})}
|
|
||||||
|
|
||||||
{isAdminUser &&
|
|
||||||
createFormField(Form.Input, {
|
|
||||||
field: 'username',
|
|
||||||
label: t('用户名称'),
|
|
||||||
value: username,
|
|
||||||
placeholder: t('可选值'),
|
|
||||||
name: 'username',
|
|
||||||
onChange: (value) => handleInputChange(value, 'username'),
|
|
||||||
})}
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchModal;
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
|
|
||||||
const FooterBar = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [footer, setFooter] = useState(getFooterHTML());
|
|
||||||
const systemName = getSystemName();
|
|
||||||
const logo = getLogo();
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
|
||||||
|
|
||||||
const loadFooter = () => {
|
|
||||||
let footer_html = localStorage.getItem('footer_html');
|
|
||||||
if (footer_html) {
|
|
||||||
setFooter(footer_html);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
const customFooter = useMemo(
|
|
||||||
() => (
|
|
||||||
<footer className='relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden'>
|
|
||||||
<div className='absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]'></div>
|
|
||||||
<div className='absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60'></div>
|
|
||||||
|
|
||||||
{isDemoSiteMode && (
|
|
||||||
<div className='flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8'>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt={systemName}
|
|
||||||
className='w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('关于我们')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/wiki/project-introduction/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('关于项目')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/support/community-interaction/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('联系我们')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/wiki/features-introduction/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('功能特性')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('文档')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/getting-started/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('快速开始')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/installation/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('安装指南')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://docs.newapi.pro/api/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
{t('API 文档')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('相关项目')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://github.com/songquanpeng/one-api'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
One API
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/novicezk/midjourney-proxy'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
Midjourney-Proxy
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/Calcium-Ion/new-api-key-tool'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
new-api-key-tool
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-left'>
|
|
||||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
|
||||||
{t('友情链接')}
|
|
||||||
</p>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<a
|
|
||||||
href='https://github.com/Calcium-Ion/new-api-horizon'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
new-api-horizon
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://github.com/coaidev/coai'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
CoAI
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href='https://www.gpt-load.com/'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='!text-semi-color-text-1'
|
|
||||||
>
|
|
||||||
GPT-Load
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6'>
|
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
|
||||||
<Typography.Text className='text-sm !text-semi-color-text-1'>
|
|
||||||
© {currentYear} {systemName}. {t('版权所有')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
),
|
|
||||||
[logo, systemName, t, currentYear, isDemoSiteMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFooter();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full'>
|
|
||||||
{footer ? (
|
|
||||||
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
|
|
||||||
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
|
|
||||||
<div
|
|
||||||
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
|
|
||||||
dangerouslySetInnerHTML={{ __html: footer }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
) : (
|
|
||||||
customFooter
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FooterBar;
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Empty,
|
|
||||||
Tabs,
|
|
||||||
TabPane,
|
|
||||||
Timeline,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { API, showError, getRelativeTime } from '../../helpers';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import {
|
|
||||||
IllustrationNoContent,
|
|
||||||
IllustrationNoContentDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
import { Bell, Megaphone } from 'lucide-react';
|
|
||||||
|
|
||||||
const NoticeModal = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
isMobile,
|
|
||||||
defaultTab = 'inApp',
|
|
||||||
unreadKeys = [],
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [noticeContent, setNoticeContent] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
||||||
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
|
|
||||||
const announcements = statusState?.status?.announcements || [];
|
|
||||||
|
|
||||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
|
||||||
|
|
||||||
const getKeyForItem = (item) =>
|
|
||||||
`${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
|
||||||
|
|
||||||
const processedAnnouncements = useMemo(() => {
|
|
||||||
return (announcements || []).slice(0, 20).map((item) => {
|
|
||||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
|
||||||
const absoluteTime =
|
|
||||||
pubDate && !isNaN(pubDate.getTime())
|
|
||||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
|
||||||
: item?.publishDate || '';
|
|
||||||
return {
|
|
||||||
key: getKeyForItem(item),
|
|
||||||
type: item.type || 'default',
|
|
||||||
time: absoluteTime,
|
|
||||||
content: item.content,
|
|
||||||
extra: item.extra,
|
|
||||||
relative: getRelativeTime(item.publishDate),
|
|
||||||
isUnread: unreadSet.has(getKeyForItem(item)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [announcements, unreadSet]);
|
|
||||||
|
|
||||||
const handleCloseTodayNotice = () => {
|
|
||||||
const today = new Date().toDateString();
|
|
||||||
localStorage.setItem('notice_close_date', today);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayNotice = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await API.get('/api/notice');
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
if (data !== '') {
|
|
||||||
const htmlNotice = marked.parse(data);
|
|
||||||
setNoticeContent(htmlNotice);
|
|
||||||
} else {
|
|
||||||
setNoticeContent('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
displayNotice();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
setActiveTab(defaultTab);
|
|
||||||
}
|
|
||||||
}, [defaultTab, visible]);
|
|
||||||
|
|
||||||
const renderMarkdownNotice = () => {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className='py-12'>
|
|
||||||
<Empty description={t('加载中...')} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!noticeContent) {
|
|
||||||
return (
|
|
||||||
<div className='py-12'>
|
|
||||||
<Empty
|
|
||||||
image={
|
|
||||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
description={t('暂无公告')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
|
||||||
className='notice-content-scroll max-h-[55vh] overflow-y-auto pr-2'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAnnouncementTimeline = () => {
|
|
||||||
if (processedAnnouncements.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className='py-12'>
|
|
||||||
<Empty
|
|
||||||
image={
|
|
||||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
description={t('暂无系统公告')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
|
|
||||||
<Timeline mode='left'>
|
|
||||||
{processedAnnouncements.map((item, idx) => {
|
|
||||||
const htmlContent = marked.parse(item.content || '');
|
|
||||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={idx}
|
|
||||||
type={item.type}
|
|
||||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
|
||||||
extra={
|
|
||||||
item.extra ? (
|
|
||||||
<div
|
|
||||||
className='text-xs text-gray-500'
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
className={item.isUnread ? '' : ''}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={item.isUnread ? 'shine-text' : ''}
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Timeline.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBody = () => {
|
|
||||||
if (activeTab === 'inApp') {
|
|
||||||
return renderMarkdownNotice();
|
|
||||||
}
|
|
||||||
return renderAnnouncementTimeline();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={
|
|
||||||
<div className='flex items-center justify-between w-full'>
|
|
||||||
<span>{t('系统公告')}</span>
|
|
||||||
<Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span className='flex items-center gap-1'>
|
|
||||||
<Bell size={14} /> {t('通知')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='inApp'
|
|
||||||
/>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span className='flex items-center gap-1'>
|
|
||||||
<Megaphone size={14} /> {t('系统公告')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
itemKey='system'
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={onClose}
|
|
||||||
footer={
|
|
||||||
<div className='flex justify-end'>
|
|
||||||
<Button type='secondary' onClick={handleCloseTodayNotice}>
|
|
||||||
{t('今日关闭')}
|
|
||||||
</Button>
|
|
||||||
<Button type='primary' onClick={onClose}>
|
|
||||||
{t('关闭公告')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
size={isMobile ? 'full-width' : 'large'}
|
|
||||||
>
|
|
||||||
{renderBody()}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NoticeModal;
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import HeaderBar from './headerbar';
|
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
|
||||||
import SiderBar from './SiderBar';
|
|
||||||
import App from '../../App';
|
|
||||||
import FooterBar from './Footer';
|
|
||||||
import { ToastContainer } from 'react-toastify';
|
|
||||||
import ErrorBoundary from '../common/ErrorBoundary';
|
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
|
||||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
|
||||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
API,
|
|
||||||
getLogo,
|
|
||||||
getSystemName,
|
|
||||||
showError,
|
|
||||||
setStatusData,
|
|
||||||
} from '../../helpers';
|
|
||||||
import { UserContext } from '../../context/User';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { normalizeLanguage } from '../../i18n/language';
|
|
||||||
const { Sider, Content, Header } = Layout;
|
|
||||||
|
|
||||||
const PageLayout = () => {
|
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
|
||||||
const [, statusDispatch] = useContext(StatusContext);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [collapsed, , setCollapsed] = useSidebarCollapsed();
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const cardProPages = [
|
|
||||||
'/console/channel',
|
|
||||||
'/console/log',
|
|
||||||
'/console/redemption',
|
|
||||||
'/console/user',
|
|
||||||
'/console/token',
|
|
||||||
'/console/midjourney',
|
|
||||||
'/console/task',
|
|
||||||
'/console/models',
|
|
||||||
'/pricing',
|
|
||||||
];
|
|
||||||
|
|
||||||
const shouldHideFooter = cardProPages.includes(location.pathname);
|
|
||||||
|
|
||||||
const shouldInnerPadding =
|
|
||||||
location.pathname.includes('/console') &&
|
|
||||||
!location.pathname.startsWith('/console/chat') &&
|
|
||||||
location.pathname !== '/console/playground';
|
|
||||||
|
|
||||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
|
||||||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
|
||||||
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobile && drawerOpen && collapsed) {
|
|
||||||
setCollapsed(false);
|
|
||||||
}
|
|
||||||
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
|
|
||||||
|
|
||||||
const loadUser = () => {
|
|
||||||
let user = localStorage.getItem('user');
|
|
||||||
if (user) {
|
|
||||||
let data = JSON.parse(user);
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadStatus = async () => {
|
|
||||||
try {
|
|
||||||
const res = await API.get('/api/status');
|
|
||||||
const { success, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
statusDispatch({ type: 'set', payload: data });
|
|
||||||
setStatusData(data);
|
|
||||||
} else {
|
|
||||||
showError('Unable to connect to server');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError('Failed to load status');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUser();
|
|
||||||
loadStatus().catch(console.error);
|
|
||||||
let systemName = getSystemName();
|
|
||||||
if (systemName) {
|
|
||||||
document.title = systemName;
|
|
||||||
}
|
|
||||||
let logo = getLogo();
|
|
||||||
if (logo) {
|
|
||||||
let linkElement = document.querySelector("link[rel~='icon']");
|
|
||||||
if (linkElement) {
|
|
||||||
linkElement.href = logo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let preferredLang;
|
|
||||||
|
|
||||||
if (userState?.user?.setting) {
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(userState.user.setting);
|
|
||||||
preferredLang = normalizeLanguage(settings.language);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preferredLang) {
|
|
||||||
const savedLang = localStorage.getItem('i18nextLng');
|
|
||||||
if (savedLang) {
|
|
||||||
preferredLang = normalizeLanguage(savedLang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferredLang) {
|
|
||||||
localStorage.setItem('i18nextLng', preferredLang);
|
|
||||||
if (preferredLang !== i18n.language) {
|
|
||||||
i18n.changeLanguage(preferredLang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [i18n, userState?.user?.setting]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
height: 'auto',
|
|
||||||
lineHeight: 'normal',
|
|
||||||
position: 'fixed',
|
|
||||||
width: '100%',
|
|
||||||
top: 0,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
/>
|
|
||||||
</Header>
|
|
||||||
<Layout
|
|
||||||
style={{
|
|
||||||
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flex: '1 1 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showSider && (
|
|
||||||
<Sider
|
|
||||||
className='app-sider'
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: 0,
|
|
||||||
top: '64px',
|
|
||||||
zIndex: 99,
|
|
||||||
border: 'none',
|
|
||||||
paddingRight: '0',
|
|
||||||
width: 'var(--sidebar-current-width)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SiderBar
|
|
||||||
onNavigate={() => {
|
|
||||||
if (isMobile) setDrawerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Sider>
|
|
||||||
)}
|
|
||||||
<Layout
|
|
||||||
style={{
|
|
||||||
marginLeft: isMobile
|
|
||||||
? '0'
|
|
||||||
: showSider
|
|
||||||
? 'var(--sidebar-current-width)'
|
|
||||||
: '0',
|
|
||||||
flex: '1 1 auto',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Content
|
|
||||||
className={isFixedLayout ? undefined : 'public-page-content'}
|
|
||||||
style={{
|
|
||||||
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
|
|
||||||
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
|
||||||
position: 'relative',
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<App />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Content>
|
|
||||||
{!shouldHideFooter && (
|
|
||||||
<Layout.Footer
|
|
||||||
style={{
|
|
||||||
flex: '0 0 auto',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FooterBar />
|
|
||||||
</Layout.Footer>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
</Layout>
|
|
||||||
<ToastContainer />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageLayout;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext, useEffect } from 'react';
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { StatusContext } from '../../context/Status';
|
|
||||||
|
|
||||||
const SetupCheck = ({ children }) => {
|
|
||||||
const [statusState] = useContext(StatusContext);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
statusState?.status?.setup === false &&
|
|
||||||
location.pathname !== '/setup'
|
|
||||||
) {
|
|
||||||
window.location.href = '/setup';
|
|
||||||
}
|
|
||||||
}, [statusState?.status?.setup, location.pathname]);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SetupCheck;
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getLucideIcon } from '../../helpers/render';
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
|
||||||
import { useSidebar } from '../../hooks/common/useSidebar';
|
|
||||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
|
||||||
import { isAdmin, isRoot, showError } from '../../helpers';
|
|
||||||
import SkeletonWrapper from './components/SkeletonWrapper';
|
|
||||||
|
|
||||||
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const routerMap = {
|
|
||||||
home: '/',
|
|
||||||
channel: '/console/channel',
|
|
||||||
token: '/console/token',
|
|
||||||
redemption: '/console/redemption',
|
|
||||||
topup: '/console/topup',
|
|
||||||
user: '/console/user',
|
|
||||||
subscription: '/console/subscription',
|
|
||||||
log: '/console/log',
|
|
||||||
midjourney: '/console/midjourney',
|
|
||||||
setting: '/console/setting',
|
|
||||||
about: '/about',
|
|
||||||
detail: '/console',
|
|
||||||
pricing: '/pricing',
|
|
||||||
task: '/console/task',
|
|
||||||
models: '/console/models',
|
|
||||||
deployment: '/console/deployment',
|
|
||||||
playground: '/console/playground',
|
|
||||||
personal: '/console/personal',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SiderBar = ({ onNavigate = () => {} }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
|
||||||
const {
|
|
||||||
isModuleVisible,
|
|
||||||
hasSectionVisibleModules,
|
|
||||||
loading: sidebarLoading,
|
|
||||||
} = useSidebar();
|
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
|
|
||||||
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
|
||||||
const [chatItems, setChatItems] = useState([]);
|
|
||||||
const [openedKeys, setOpenedKeys] = useState([]);
|
|
||||||
const location = useLocation();
|
|
||||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
|
||||||
|
|
||||||
const workspaceItems = useMemo(() => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
text: t('数据看板'),
|
|
||||||
itemKey: 'detail',
|
|
||||||
to: '/detail',
|
|
||||||
className:
|
|
||||||
localStorage.getItem('enable_data_export') === 'true'
|
|
||||||
? ''
|
|
||||||
: 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('令牌管理'),
|
|
||||||
itemKey: 'token',
|
|
||||||
to: '/token',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('使用日志'),
|
|
||||||
itemKey: 'log',
|
|
||||||
to: '/log',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('绘图日志'),
|
|
||||||
itemKey: 'midjourney',
|
|
||||||
to: '/midjourney',
|
|
||||||
className:
|
|
||||||
localStorage.getItem('enable_drawing') === 'true'
|
|
||||||
? ''
|
|
||||||
: 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('任务日志'),
|
|
||||||
itemKey: 'task',
|
|
||||||
to: '/task',
|
|
||||||
className:
|
|
||||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据配置过滤项目
|
|
||||||
const filteredItems = items.filter((item) => {
|
|
||||||
const configVisible = isModuleVisible('console', item.itemKey);
|
|
||||||
return configVisible;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredItems;
|
|
||||||
}, [
|
|
||||||
localStorage.getItem('enable_data_export'),
|
|
||||||
localStorage.getItem('enable_drawing'),
|
|
||||||
localStorage.getItem('enable_task'),
|
|
||||||
t,
|
|
||||||
isModuleVisible,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const financeItems = useMemo(() => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
text: t('钱包管理'),
|
|
||||||
itemKey: 'topup',
|
|
||||||
to: '/topup',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('个人设置'),
|
|
||||||
itemKey: 'personal',
|
|
||||||
to: '/personal',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据配置过滤项目
|
|
||||||
const filteredItems = items.filter((item) => {
|
|
||||||
const configVisible = isModuleVisible('personal', item.itemKey);
|
|
||||||
return configVisible;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredItems;
|
|
||||||
}, [t, isModuleVisible]);
|
|
||||||
|
|
||||||
const adminItems = useMemo(() => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
text: t('渠道管理'),
|
|
||||||
itemKey: 'channel',
|
|
||||||
to: '/channel',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('订阅管理'),
|
|
||||||
itemKey: 'subscription',
|
|
||||||
to: '/subscription',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('模型管理'),
|
|
||||||
itemKey: 'models',
|
|
||||||
to: '/console/models',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('模型部署'),
|
|
||||||
itemKey: 'deployment',
|
|
||||||
to: '/deployment',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('兑换码管理'),
|
|
||||||
itemKey: 'redemption',
|
|
||||||
to: '/redemption',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('用户管理'),
|
|
||||||
itemKey: 'user',
|
|
||||||
to: '/user',
|
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('系统设置'),
|
|
||||||
itemKey: 'setting',
|
|
||||||
to: '/setting',
|
|
||||||
className: isRoot() ? '' : 'tableHiddle',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据配置过滤项目
|
|
||||||
const filteredItems = items.filter((item) => {
|
|
||||||
const configVisible = isModuleVisible('admin', item.itemKey);
|
|
||||||
return configVisible;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredItems;
|
|
||||||
}, [isAdmin(), isRoot(), t, isModuleVisible]);
|
|
||||||
|
|
||||||
const chatMenuItems = useMemo(() => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
text: t('操练场'),
|
|
||||||
itemKey: 'playground',
|
|
||||||
to: '/playground',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('聊天'),
|
|
||||||
itemKey: 'chat',
|
|
||||||
items: chatItems,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据配置过滤项目
|
|
||||||
const filteredItems = items.filter((item) => {
|
|
||||||
const configVisible = isModuleVisible('chat', item.itemKey);
|
|
||||||
return configVisible;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filteredItems;
|
|
||||||
}, [chatItems, t, isModuleVisible]);
|
|
||||||
|
|
||||||
// 更新路由映射,添加聊天路由
|
|
||||||
const updateRouterMapWithChats = (chats) => {
|
|
||||||
const newRouterMap = { ...routerMap };
|
|
||||||
|
|
||||||
if (Array.isArray(chats) && chats.length > 0) {
|
|
||||||
for (let i = 0; i < chats.length; i++) {
|
|
||||||
newRouterMap['chat' + i] = '/console/chat/' + i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRouterMapState(newRouterMap);
|
|
||||||
return newRouterMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载聊天项
|
|
||||||
useEffect(() => {
|
|
||||||
let chats = localStorage.getItem('chats');
|
|
||||||
if (chats) {
|
|
||||||
try {
|
|
||||||
chats = JSON.parse(chats);
|
|
||||||
if (Array.isArray(chats)) {
|
|
||||||
let chatItems = [];
|
|
||||||
for (let i = 0; i < chats.length; i++) {
|
|
||||||
let shouldSkip = false;
|
|
||||||
let chat = {};
|
|
||||||
for (let key in chats[i]) {
|
|
||||||
let link = chats[i][key];
|
|
||||||
if (typeof link !== 'string') continue; // 确保链接是字符串
|
|
||||||
if (
|
|
||||||
link.startsWith('fluent') ||
|
|
||||||
link.startsWith('ccswitch') ||
|
|
||||||
link.startsWith('deepchat')
|
|
||||||
) {
|
|
||||||
shouldSkip = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
chat.text = key;
|
|
||||||
chat.itemKey = 'chat' + i;
|
|
||||||
chat.to = '/console/chat/' + i;
|
|
||||||
}
|
|
||||||
if (shouldSkip || !chat.text) continue; // 避免推入空项
|
|
||||||
chatItems.push(chat);
|
|
||||||
}
|
|
||||||
setChatItems(chatItems);
|
|
||||||
updateRouterMapWithChats(chats);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showError('聊天数据解析失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 根据当前路径设置选中的菜单项
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
let matchingKey = Object.keys(routerMapState).find(
|
|
||||||
(key) => routerMapState[key] === currentPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理聊天路由
|
|
||||||
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
|
|
||||||
const chatIndex = currentPath.split('/').pop();
|
|
||||||
if (!isNaN(chatIndex)) {
|
|
||||||
matchingKey = 'chat' + chatIndex;
|
|
||||||
} else {
|
|
||||||
matchingKey = 'chat';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果找到匹配的键,更新选中的键
|
|
||||||
if (matchingKey) {
|
|
||||||
setSelectedKeys([matchingKey]);
|
|
||||||
}
|
|
||||||
}, [location.pathname, routerMapState]);
|
|
||||||
|
|
||||||
// 监控折叠状态变化以更新 body class
|
|
||||||
useEffect(() => {
|
|
||||||
if (collapsed) {
|
|
||||||
document.body.classList.add('sidebar-collapsed');
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove('sidebar-collapsed');
|
|
||||||
}
|
|
||||||
}, [collapsed]);
|
|
||||||
|
|
||||||
// 选中高亮颜色(统一)
|
|
||||||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
|
||||||
|
|
||||||
// 渲染自定义菜单项
|
|
||||||
const renderNavItem = (item) => {
|
|
||||||
// 跳过隐藏的项目
|
|
||||||
if (item.className === 'tableHiddle') return null;
|
|
||||||
|
|
||||||
const isSelected = selectedKeys.includes(item.itemKey);
|
|
||||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Nav.Item
|
|
||||||
key={item.itemKey}
|
|
||||||
itemKey={item.itemKey}
|
|
||||||
text={
|
|
||||||
<span
|
|
||||||
className='truncate font-medium text-sm'
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
icon={
|
|
||||||
<div className='sidebar-icon-container flex-shrink-0'>
|
|
||||||
{getLucideIcon(item.itemKey, isSelected)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className={item.className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染子菜单项
|
|
||||||
const renderSubItem = (item) => {
|
|
||||||
if (item.items && item.items.length > 0) {
|
|
||||||
const isSelected = selectedKeys.includes(item.itemKey);
|
|
||||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Nav.Sub
|
|
||||||
key={item.itemKey}
|
|
||||||
itemKey={item.itemKey}
|
|
||||||
text={
|
|
||||||
<span
|
|
||||||
className='truncate font-medium text-sm'
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
icon={
|
|
||||||
<div className='sidebar-icon-container flex-shrink-0'>
|
|
||||||
{getLucideIcon(item.itemKey, isSelected)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.items.map((subItem) => {
|
|
||||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
|
||||||
const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Nav.Item
|
|
||||||
key={subItem.itemKey}
|
|
||||||
itemKey={subItem.itemKey}
|
|
||||||
text={
|
|
||||||
<span
|
|
||||||
className='truncate font-medium text-sm'
|
|
||||||
style={{ color: subTextColor }}
|
|
||||||
>
|
|
||||||
{subItem.text}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Nav.Sub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return renderNavItem(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='sidebar-container'
|
|
||||||
style={{
|
|
||||||
width: 'var(--sidebar-current-width)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SkeletonWrapper
|
|
||||||
loading={showSkeleton}
|
|
||||||
type='sidebar'
|
|
||||||
className=''
|
|
||||||
collapsed={collapsed}
|
|
||||||
showAdmin={isAdmin()}
|
|
||||||
>
|
|
||||||
<Nav
|
|
||||||
className='sidebar-nav'
|
|
||||||
defaultIsCollapsed={collapsed}
|
|
||||||
isCollapsed={collapsed}
|
|
||||||
onCollapseChange={toggleCollapsed}
|
|
||||||
selectedKeys={selectedKeys}
|
|
||||||
itemStyle='sidebar-nav-item'
|
|
||||||
hoverStyle='sidebar-nav-item:hover'
|
|
||||||
selectedStyle='sidebar-nav-item-selected'
|
|
||||||
renderWrapper={({ itemElement, props }) => {
|
|
||||||
const to =
|
|
||||||
routerMapState[props.itemKey] || routerMap[props.itemKey];
|
|
||||||
|
|
||||||
// 如果没有路由,直接返回元素
|
|
||||||
if (!to) return itemElement;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
to={to}
|
|
||||||
onClick={onNavigate}
|
|
||||||
>
|
|
||||||
{itemElement}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onSelect={(key) => {
|
|
||||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
|
||||||
if (openedKeys.includes(key.itemKey)) {
|
|
||||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedKeys([key.itemKey]);
|
|
||||||
}}
|
|
||||||
openKeys={openedKeys}
|
|
||||||
onOpenChange={(data) => {
|
|
||||||
setOpenedKeys(data.openKeys);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 聊天区域 */}
|
|
||||||
{hasSectionVisibleModules('chat') && (
|
|
||||||
<div className='sidebar-section'>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className='sidebar-group-label'>{t('聊天')}</div>
|
|
||||||
)}
|
|
||||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 控制台区域 */}
|
|
||||||
{hasSectionVisibleModules('console') && (
|
|
||||||
<>
|
|
||||||
<Divider className='sidebar-divider' />
|
|
||||||
<div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
|
||||||
)}
|
|
||||||
{workspaceItems.map((item) => renderNavItem(item))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 个人中心区域 */}
|
|
||||||
{hasSectionVisibleModules('personal') && (
|
|
||||||
<>
|
|
||||||
<Divider className='sidebar-divider' />
|
|
||||||
<div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
|
||||||
)}
|
|
||||||
{financeItems.map((item) => renderNavItem(item))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
|
|
||||||
{isAdmin() && hasSectionVisibleModules('admin') && (
|
|
||||||
<>
|
|
||||||
<Divider className='sidebar-divider' />
|
|
||||||
<div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className='sidebar-group-label'>{t('管理员')}</div>
|
|
||||||
)}
|
|
||||||
{adminItems.map((item) => renderNavItem(item))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Nav>
|
|
||||||
</SkeletonWrapper>
|
|
||||||
|
|
||||||
{/* 底部折叠按钮 */}
|
|
||||||
<div className='sidebar-collapse-button'>
|
|
||||||
<SkeletonWrapper
|
|
||||||
loading={showSkeleton}
|
|
||||||
type='button'
|
|
||||||
width={collapsed ? 36 : 156}
|
|
||||||
height={24}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='outline'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
<ChevronLeft
|
|
||||||
size={16}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
color='var(--semi-color-text-2)'
|
|
||||||
style={{
|
|
||||||
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
icononly={collapsed}
|
|
||||||
style={
|
|
||||||
collapsed
|
|
||||||
? { width: 36, height: 24, padding: 0 }
|
|
||||||
: { padding: '4px 12px', width: '100%' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!collapsed ? t('收起侧边栏') : null}
|
|
||||||
</Button>
|
|
||||||
</SkeletonWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SiderBar;
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Skeleton } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
const SkeletonWrapper = ({
|
|
||||||
loading = false,
|
|
||||||
type = 'text',
|
|
||||||
count = 1,
|
|
||||||
width = 60,
|
|
||||||
height = 16,
|
|
||||||
isMobile = false,
|
|
||||||
className = '',
|
|
||||||
collapsed = false,
|
|
||||||
showAdmin = true,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
if (!loading) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导航链接骨架屏
|
|
||||||
const renderNavigationSkeleton = () => {
|
|
||||||
const skeletonLinkClasses = isMobile
|
|
||||||
? 'flex items-center gap-1 p-1 w-full rounded-md'
|
|
||||||
: 'flex items-center gap-1 p-2 rounded-md';
|
|
||||||
|
|
||||||
return Array(count)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => (
|
|
||||||
<div key={index} className={skeletonLinkClasses}>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: isMobile ? 40 : width, height }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 用户区域骨架屏 (头像 + 文本)
|
|
||||||
const renderUserAreaSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Avatar size='extra-small' className='shadow-sm' />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className='ml-1.5 mr-1'>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: isMobile ? 15 : width, height: 12 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Logo图片骨架屏
|
|
||||||
const renderImageSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Image
|
|
||||||
className={`absolute inset-0 !rounded-full ${className}`}
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 系统名称骨架屏
|
|
||||||
const renderTitleSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={<Skeleton.Title style={{ width, height: 24 }} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 通用文本骨架屏
|
|
||||||
const renderTextSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={<Skeleton.Title style={{ width, height }} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 按钮骨架屏(支持圆角)
|
|
||||||
const renderButtonSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 侧边栏导航项骨架屏 (图标 + 文本)
|
|
||||||
const renderSidebarNavItemSkeleton = () => {
|
|
||||||
return Array(count)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex items-center p-2 mb-1 rounded-md ${className}`}
|
|
||||||
>
|
|
||||||
{/* 图标骨架屏 */}
|
|
||||||
<div className='sidebar-icon-container flex-shrink-0'>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Avatar size='extra-small' shape='square' />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 文本骨架屏 */}
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: width || 80, height: height || 14 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 侧边栏组标题骨架屏
|
|
||||||
const renderSidebarGroupTitleSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div className={`mb-2 ${className}`}>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: width || 60, height: height || 12 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 完整侧边栏骨架屏 - 1:1 还原,去重实现
|
|
||||||
const renderSidebarSkeleton = () => {
|
|
||||||
const NAV_WIDTH = 164;
|
|
||||||
const NAV_HEIGHT = 30;
|
|
||||||
const COLLAPSED_WIDTH = 44;
|
|
||||||
const COLLAPSED_HEIGHT = 44;
|
|
||||||
const ICON_SIZE = 16;
|
|
||||||
const TITLE_HEIGHT = 12;
|
|
||||||
const TEXT_HEIGHT = 16;
|
|
||||||
|
|
||||||
const renderIcon = () => (
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Avatar
|
|
||||||
shape='square'
|
|
||||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderLabel = (labelWidth) => (
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const NavRow = ({ labelWidth }) => (
|
|
||||||
<div
|
|
||||||
className='flex items-center p-2 mb-1 rounded-md'
|
|
||||||
style={{
|
|
||||||
width: `${NAV_WIDTH}px`,
|
|
||||||
height: `${NAV_HEIGHT}px`,
|
|
||||||
margin: '3px 8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='sidebar-icon-container flex-shrink-0'>
|
|
||||||
{renderIcon()}
|
|
||||||
</div>
|
|
||||||
{renderLabel(labelWidth)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CollapsedRow = ({ keyPrefix, index }) => (
|
|
||||||
<div
|
|
||||||
key={`${keyPrefix}-${index}`}
|
|
||||||
className='flex items-center justify-center'
|
|
||||||
style={{
|
|
||||||
width: `${COLLAPSED_WIDTH}px`,
|
|
||||||
height: `${COLLAPSED_HEIGHT}px`,
|
|
||||||
margin: '0 8px 4px 8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Avatar
|
|
||||||
shape='square'
|
|
||||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
|
||||||
{Array(2)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => (
|
|
||||||
<CollapsedRow keyPrefix='c-chat' index={i} />
|
|
||||||
))}
|
|
||||||
{Array(5)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => (
|
|
||||||
<CollapsedRow keyPrefix='c-console' index={i} />
|
|
||||||
))}
|
|
||||||
{Array(2)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => (
|
|
||||||
<CollapsedRow keyPrefix='c-personal' index={i} />
|
|
||||||
))}
|
|
||||||
{Array(5)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => (
|
|
||||||
<CollapsedRow keyPrefix='c-admin' index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{ key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' },
|
|
||||||
{ key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] },
|
|
||||||
{ key: 'personal', titleWidth: 64, itemWidths: [64, 64] },
|
|
||||||
...(showAdmin
|
|
||||||
? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
|
||||||
{sections.map((sec, idx) => (
|
|
||||||
<React.Fragment key={sec.key}>
|
|
||||||
{sec.wrapper === 'section' ? (
|
|
||||||
<div className='sidebar-section'>
|
|
||||||
<div
|
|
||||||
className='sidebar-group-label'
|
|
||||||
style={{ padding: '4px 15px 8px' }}
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{sec.itemWidths.map((w, i) => (
|
|
||||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className='sidebar-group-label'
|
|
||||||
style={{ padding: '4px 15px 8px' }}
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
loading={true}
|
|
||||||
active
|
|
||||||
placeholder={
|
|
||||||
<Skeleton.Title
|
|
||||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{sec.itemWidths.map((w, i) => (
|
|
||||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据类型渲染不同的骨架屏
|
|
||||||
switch (type) {
|
|
||||||
case 'navigation':
|
|
||||||
return renderNavigationSkeleton();
|
|
||||||
case 'userArea':
|
|
||||||
return renderUserAreaSkeleton();
|
|
||||||
case 'image':
|
|
||||||
return renderImageSkeleton();
|
|
||||||
case 'title':
|
|
||||||
return renderTitleSkeleton();
|
|
||||||
case 'sidebarNavItem':
|
|
||||||
return renderSidebarNavItemSkeleton();
|
|
||||||
case 'sidebarGroupTitle':
|
|
||||||
return renderSidebarGroupTitleSkeleton();
|
|
||||||
case 'sidebar':
|
|
||||||
return renderSidebarSkeleton();
|
|
||||||
case 'button':
|
|
||||||
return renderButtonSkeleton();
|
|
||||||
case 'text':
|
|
||||||
default:
|
|
||||||
return renderTextSkeleton();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SkeletonWrapper;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import NewYearButton from './NewYearButton';
|
|
||||||
import NotificationButton from './NotificationButton';
|
|
||||||
import ThemeToggle from './ThemeToggle';
|
|
||||||
import LanguageSelector from './LanguageSelector';
|
|
||||||
import UserArea from './UserArea';
|
|
||||||
|
|
||||||
const ActionButtons = ({
|
|
||||||
isNewYear,
|
|
||||||
unreadCount,
|
|
||||||
onNoticeOpen,
|
|
||||||
theme,
|
|
||||||
onThemeToggle,
|
|
||||||
currentLang,
|
|
||||||
onLanguageChange,
|
|
||||||
userState,
|
|
||||||
isLoading,
|
|
||||||
isMobile,
|
|
||||||
isSelfUseMode,
|
|
||||||
logout,
|
|
||||||
navigate,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center gap-2 md:gap-3'>
|
|
||||||
<NewYearButton isNewYear={isNewYear} />
|
|
||||||
|
|
||||||
<NotificationButton
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
onNoticeOpen={onNoticeOpen}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
|
|
||||||
|
|
||||||
<LanguageSelector
|
|
||||||
currentLang={currentLang}
|
|
||||||
onLanguageChange={onLanguageChange}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UserArea
|
|
||||||
userState={userState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isMobile={isMobile}
|
|
||||||
isSelfUseMode={isSelfUseMode}
|
|
||||||
logout={logout}
|
|
||||||
navigate={navigate}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActionButtons;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Typography, Tag } from '@douyinfe/semi-ui';
|
|
||||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
|
||||||
|
|
||||||
const HeaderLogo = ({
|
|
||||||
isMobile,
|
|
||||||
isConsoleRoute,
|
|
||||||
logo,
|
|
||||||
logoLoaded,
|
|
||||||
isLoading,
|
|
||||||
systemName,
|
|
||||||
isSelfUseMode,
|
|
||||||
isDemoSiteMode,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
if (isMobile && isConsoleRoute) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to='/' className='group flex items-center gap-2'>
|
|
||||||
<div className='relative w-8 h-8 md:w-8 md:h-8'>
|
|
||||||
<SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt='logo'
|
|
||||||
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='hidden md:flex items-center gap-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<SkeletonWrapper
|
|
||||||
loading={isLoading}
|
|
||||||
type='title'
|
|
||||||
width={120}
|
|
||||||
height={24}
|
|
||||||
>
|
|
||||||
<Typography.Title
|
|
||||||
heading={4}
|
|
||||||
className='!text-lg !font-semibold !mb-0'
|
|
||||||
>
|
|
||||||
{systemName}
|
|
||||||
</Typography.Title>
|
|
||||||
</SkeletonWrapper>
|
|
||||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
|
||||||
<Tag
|
|
||||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
|
||||||
className='text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm'
|
|
||||||
size='small'
|
|
||||||
shape='circle'
|
|
||||||
>
|
|
||||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeaderLogo;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
|
||||||
import { Languages } from 'lucide-react';
|
|
||||||
|
|
||||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
position='bottomRight'
|
|
||||||
render={
|
|
||||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
|
||||||
{/* Language sorting: Order by English name (Chinese, English, French, Japanese, Russian) */}
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('zh-CN')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh-CN' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
简体中文
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('zh-TW')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh-TW' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
繁體中文
|
|
||||||
</Dropdown.Item> <Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('en')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('fr')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
Français
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('ja')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'ja' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
日本語
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('ru')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'ru' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
Русский
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => onLanguageChange('vi')}
|
|
||||||
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'vi' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
|
||||||
>
|
|
||||||
Tiếng Việt
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<Languages size={18} />}
|
|
||||||
aria-label={t('common.changeLanguage')}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LanguageSelector;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
|
||||||
import { IconClose, IconMenu } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
const MobileMenuButton = ({
|
|
||||||
isConsoleRoute,
|
|
||||||
isMobile,
|
|
||||||
drawerOpen,
|
|
||||||
collapsed,
|
|
||||||
onToggle,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
if (!isConsoleRoute || !isMobile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
(isMobile ? drawerOpen : collapsed) ? (
|
|
||||||
<IconClose className='text-lg' />
|
|
||||||
) : (
|
|
||||||
<IconMenu className='text-lg' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')
|
|
||||||
}
|
|
||||||
onClick={onToggle}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MobileMenuButton;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
|
||||||
|
|
||||||
const Navigation = ({
|
|
||||||
mainNavLinks,
|
|
||||||
isMobile,
|
|
||||||
isLoading,
|
|
||||||
userState,
|
|
||||||
pricingRequireAuth,
|
|
||||||
}) => {
|
|
||||||
const renderNavLinks = () => {
|
|
||||||
const baseClasses =
|
|
||||||
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
|
|
||||||
const hoverClasses = 'hover:text-semi-color-primary';
|
|
||||||
const spacingClasses = isMobile ? 'p-1' : 'p-2';
|
|
||||||
|
|
||||||
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
|
|
||||||
|
|
||||||
return mainNavLinks.map((link) => {
|
|
||||||
const linkContent = <span>{link.text}</span>;
|
|
||||||
|
|
||||||
if (link.isExternal) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={link.itemKey}
|
|
||||||
href={link.externalLink}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className={commonLinkClasses}
|
|
||||||
>
|
|
||||||
{linkContent}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetPath = link.to;
|
|
||||||
if (link.itemKey === 'console' && !userState.user) {
|
|
||||||
targetPath = '/login';
|
|
||||||
}
|
|
||||||
if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) {
|
|
||||||
targetPath = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
|
|
||||||
{linkContent}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className='flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide'>
|
|
||||||
<SkeletonWrapper
|
|
||||||
loading={isLoading}
|
|
||||||
type='navigation'
|
|
||||||
count={4}
|
|
||||||
width={60}
|
|
||||||
height={16}
|
|
||||||
isMobile={isMobile}
|
|
||||||
>
|
|
||||||
{renderNavLinks()}
|
|
||||||
</SkeletonWrapper>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Navigation;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
|
||||||
import fireworks from 'react-fireworks';
|
|
||||||
|
|
||||||
const NewYearButton = ({ isNewYear }) => {
|
|
||||||
if (!isNewYear) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewYearClick = () => {
|
|
||||||
fireworks.init('root', {});
|
|
||||||
fireworks.start();
|
|
||||||
setTimeout(() => {
|
|
||||||
fireworks.stop();
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
position='bottomRight'
|
|
||||||
render={
|
|
||||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={handleNewYearClick}
|
|
||||||
className='!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600'
|
|
||||||
>
|
|
||||||
Happy New Year!!! 🎉
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
icon={<span className='text-xl'>🎉</span>}
|
|
||||||
aria-label='New Year'
|
|
||||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full'
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewYearButton;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button, Badge } from '@douyinfe/semi-ui';
|
|
||||||
import { Bell } from 'lucide-react';
|
|
||||||
|
|
||||||
const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
|
|
||||||
const buttonProps = {
|
|
||||||
icon: <Bell size={18} />,
|
|
||||||
'aria-label': t('系统公告'),
|
|
||||||
onClick: onNoticeOpen,
|
|
||||||
theme: 'borderless',
|
|
||||||
type: 'tertiary',
|
|
||||||
className:
|
|
||||||
'!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (unreadCount > 0) {
|
|
||||||
return (
|
|
||||||
<Badge count={unreadCount} type='danger' overflowCount={99}>
|
|
||||||
<Button {...buttonProps} />
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Button {...buttonProps} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotificationButton;
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
|
||||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
|
||||||
import { useActualTheme } from '../../../context/Theme';
|
|
||||||
|
|
||||||
const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
|
||||||
const actualTheme = useActualTheme();
|
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'light',
|
|
||||||
icon: <Sun size={18} />,
|
|
||||||
buttonIcon: <Sun size={18} />,
|
|
||||||
label: t('浅色模式'),
|
|
||||||
description: t('始终使用浅色主题'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dark',
|
|
||||||
icon: <Moon size={18} />,
|
|
||||||
buttonIcon: <Moon size={18} />,
|
|
||||||
label: t('深色模式'),
|
|
||||||
description: t('始终使用深色主题'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'auto',
|
|
||||||
icon: <Monitor size={18} />,
|
|
||||||
buttonIcon: <Monitor size={18} />,
|
|
||||||
label: t('自动模式'),
|
|
||||||
description: t('跟随系统主题设置'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getItemClassName = (isSelected) =>
|
|
||||||
isSelected
|
|
||||||
? '!bg-semi-color-primary-light-default !font-semibold'
|
|
||||||
: 'hover:!bg-semi-color-fill-1';
|
|
||||||
|
|
||||||
const currentButtonIcon = useMemo(() => {
|
|
||||||
const currentOption = themeOptions.find((option) => option.key === theme);
|
|
||||||
return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
|
|
||||||
}, [theme, themeOptions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
position='bottomRight'
|
|
||||||
render={
|
|
||||||
<Dropdown.Menu>
|
|
||||||
{themeOptions.map((option) => (
|
|
||||||
<Dropdown.Item
|
|
||||||
key={option.key}
|
|
||||||
icon={option.icon}
|
|
||||||
onClick={() => onThemeToggle(option.key)}
|
|
||||||
className={getItemClassName(theme === option.key)}
|
|
||||||
>
|
|
||||||
<div className='flex flex-col'>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
<span className='text-xs text-semi-color-text-2'>
|
|
||||||
{option.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown.Item>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{theme === 'auto' && (
|
|
||||||
<>
|
|
||||||
<Dropdown.Divider />
|
|
||||||
<div className='px-3 py-2 text-xs text-semi-color-text-2'>
|
|
||||||
{t('当前跟随系统')}:
|
|
||||||
{actualTheme === 'dark' ? t('深色') : t('浅色')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className='inline-flex'>
|
|
||||||
<Button
|
|
||||||
icon={currentButtonIcon}
|
|
||||||
aria-label={t('切换主题')}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeToggle;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
IconExit,
|
|
||||||
IconUserSetting,
|
|
||||||
IconCreditCard,
|
|
||||||
IconKey,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import { stringToColor } from '../../../helpers';
|
|
||||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
|
||||||
|
|
||||||
const UserArea = ({
|
|
||||||
userState,
|
|
||||||
isLoading,
|
|
||||||
isMobile,
|
|
||||||
isSelfUseMode,
|
|
||||||
logout,
|
|
||||||
navigate,
|
|
||||||
t,
|
|
||||||
}) => {
|
|
||||||
const dropdownRef = useRef(null);
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<SkeletonWrapper
|
|
||||||
loading={true}
|
|
||||||
type='userArea'
|
|
||||||
width={50}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userState.user) {
|
|
||||||
return (
|
|
||||||
<div className='relative' ref={dropdownRef}>
|
|
||||||
<Dropdown
|
|
||||||
position='bottomRight'
|
|
||||||
getPopupContainer={() => dropdownRef.current}
|
|
||||||
render={
|
|
||||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/console/personal');
|
|
||||||
}}
|
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<IconUserSetting
|
|
||||||
size='small'
|
|
||||||
className='text-gray-500 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
<span>{t('个人设置')}</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/console/token');
|
|
||||||
}}
|
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<IconKey
|
|
||||||
size='small'
|
|
||||||
className='text-gray-500 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
<span>{t('令牌管理')}</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/console/topup');
|
|
||||||
}}
|
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<IconCreditCard
|
|
||||||
size='small'
|
|
||||||
className='text-gray-500 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
<span>{t('钱包管理')}</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item
|
|
||||||
onClick={logout}
|
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<IconExit
|
|
||||||
size='small'
|
|
||||||
className='text-gray-500 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
<span>{t('退出')}</span>
|
|
||||||
</div>
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
size='extra-small'
|
|
||||||
color={stringToColor(userState.user.username)}
|
|
||||||
className='mr-1'
|
|
||||||
>
|
|
||||||
{userState.user.username[0].toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<span className='hidden md:inline'>
|
|
||||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
|
||||||
{userState.user.username}
|
|
||||||
</Typography.Text>
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
size={14}
|
|
||||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const showRegisterButton = !isSelfUseMode;
|
|
||||||
|
|
||||||
const commonSizingAndLayoutClass =
|
|
||||||
'flex items-center justify-center !py-[10px] !px-1.5';
|
|
||||||
|
|
||||||
const loginButtonSpecificStyling =
|
|
||||||
'!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors';
|
|
||||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
|
||||||
|
|
||||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
|
||||||
|
|
||||||
const loginButtonTextSpanClass =
|
|
||||||
'!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5';
|
|
||||||
const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5';
|
|
||||||
|
|
||||||
if (showRegisterButton) {
|
|
||||||
if (isMobile) {
|
|
||||||
loginButtonClasses += ' !rounded-full';
|
|
||||||
} else {
|
|
||||||
loginButtonClasses += ' !rounded-l-full !rounded-r-none';
|
|
||||||
}
|
|
||||||
registerButtonClasses += ' !rounded-r-full !rounded-l-none';
|
|
||||||
} else {
|
|
||||||
loginButtonClasses += ' !rounded-full';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<Link to='/login' className='flex'>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className={loginButtonClasses}
|
|
||||||
>
|
|
||||||
<span className={loginButtonTextSpanClass}>{t('登录')}</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{showRegisterButton && (
|
|
||||||
<div className='hidden md:block'>
|
|
||||||
<Link to='/register' className='flex -ml-px'>
|
|
||||||
<Button
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
className={registerButtonClasses}
|
|
||||||
>
|
|
||||||
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserArea;
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useHeaderBar } from '../../../hooks/common/useHeaderBar';
|
|
||||||
import { useNotifications } from '../../../hooks/common/useNotifications';
|
|
||||||
import { useNavigation } from '../../../hooks/common/useNavigation';
|
|
||||||
import NoticeModal from '../NoticeModal';
|
|
||||||
import MobileMenuButton from './MobileMenuButton';
|
|
||||||
import HeaderLogo from './HeaderLogo';
|
|
||||||
import Navigation from './Navigation';
|
|
||||||
import ActionButtons from './ActionButtons';
|
|
||||||
|
|
||||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
|
||||||
const {
|
|
||||||
userState,
|
|
||||||
statusState,
|
|
||||||
isMobile,
|
|
||||||
collapsed,
|
|
||||||
logoLoaded,
|
|
||||||
currentLang,
|
|
||||||
isLoading,
|
|
||||||
systemName,
|
|
||||||
logo,
|
|
||||||
isNewYear,
|
|
||||||
isSelfUseMode,
|
|
||||||
docsLink,
|
|
||||||
isDemoSiteMode,
|
|
||||||
isConsoleRoute,
|
|
||||||
theme,
|
|
||||||
headerNavModules,
|
|
||||||
pricingRequireAuth,
|
|
||||||
logout,
|
|
||||||
handleLanguageChange,
|
|
||||||
handleThemeToggle,
|
|
||||||
handleMobileMenuToggle,
|
|
||||||
navigate,
|
|
||||||
t,
|
|
||||||
} = useHeaderBar({ onMobileMenuToggle, drawerOpen });
|
|
||||||
|
|
||||||
const {
|
|
||||||
noticeVisible,
|
|
||||||
unreadCount,
|
|
||||||
handleNoticeOpen,
|
|
||||||
handleNoticeClose,
|
|
||||||
getUnreadKeys,
|
|
||||||
} = useNotifications(statusState);
|
|
||||||
|
|
||||||
const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className='text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg'>
|
|
||||||
<NoticeModal
|
|
||||||
visible={noticeVisible}
|
|
||||||
onClose={handleNoticeClose}
|
|
||||||
isMobile={isMobile}
|
|
||||||
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
|
||||||
unreadKeys={getUnreadKeys()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='w-full px-2'>
|
|
||||||
<div className='flex items-center justify-between h-16'>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<MobileMenuButton
|
|
||||||
isConsoleRoute={isConsoleRoute}
|
|
||||||
isMobile={isMobile}
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onToggle={handleMobileMenuToggle}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeaderLogo
|
|
||||||
isMobile={isMobile}
|
|
||||||
isConsoleRoute={isConsoleRoute}
|
|
||||||
logo={logo}
|
|
||||||
logoLoaded={logoLoaded}
|
|
||||||
isLoading={isLoading}
|
|
||||||
systemName={systemName}
|
|
||||||
isSelfUseMode={isSelfUseMode}
|
|
||||||
isDemoSiteMode={isDemoSiteMode}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Navigation
|
|
||||||
mainNavLinks={mainNavLinks}
|
|
||||||
isMobile={isMobile}
|
|
||||||
isLoading={isLoading}
|
|
||||||
userState={userState}
|
|
||||||
pricingRequireAuth={pricingRequireAuth}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButtons
|
|
||||||
isNewYear={isNewYear}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
onNoticeOpen={handleNoticeOpen}
|
|
||||||
theme={theme}
|
|
||||||
onThemeToggle={handleThemeToggle}
|
|
||||||
currentLang={currentLang}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
userState={userState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isMobile={isMobile}
|
|
||||||
isSelfUseMode={isSelfUseMode}
|
|
||||||
logout={logout}
|
|
||||||
navigate={navigate}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeaderBar;
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Button, Typography } from '@douyinfe/semi-ui';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Settings, Server, AlertCircle, WifiOff } from 'lucide-react';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
const DeploymentAccessGuard = ({
|
|
||||||
children,
|
|
||||||
loading,
|
|
||||||
isEnabled,
|
|
||||||
connectionLoading,
|
|
||||||
connectionOk,
|
|
||||||
connectionError,
|
|
||||||
onRetry,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleGoToSettings = () => {
|
|
||||||
navigate('/console/setting?tab=model-deployment');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className='mt-[60px] px-2'>
|
|
||||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
|
||||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
|
||||||
<Text type='secondary'>{t('加载设置中...')}</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='mt-[60px] px-4'
|
|
||||||
style={{
|
|
||||||
minHeight: 'calc(100vh - 60px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '600px',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '0 20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
padding: '60px 40px',
|
|
||||||
borderRadius: '16px',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 图标区域 */}
|
|
||||||
<div style={{ marginBottom: '32px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '120px',
|
|
||||||
height: '120px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
|
|
||||||
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
|
|
||||||
marginBottom: '24px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertCircle size={56} color='var(--semi-color-warning)' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<Title
|
|
||||||
heading={2}
|
|
||||||
style={{
|
|
||||||
color: 'var(--semi-color-text-0)',
|
|
||||||
margin: '0 0 12px 0',
|
|
||||||
fontSize: '28px',
|
|
||||||
fontWeight: '700',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('模型部署服务未启用')}
|
|
||||||
</Title>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
color: 'var(--semi-color-text-1)',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('访问模型部署功能需要先启用 io.net 部署服务')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 配置要求区域 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--semi-color-bg-1)',
|
|
||||||
padding: '24px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
margin: '32px 0',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Server size={20} color='var(--semi-color-primary)' />
|
|
||||||
</div>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
color: 'var(--semi-color-text-0)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('需要配置的项目')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '12px',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
textAlign: 'left',
|
|
||||||
maxWidth: '320px',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'var(--semi-color-primary)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
color: 'var(--semi-color-text-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('启用 io.net 部署开关')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '6px',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'var(--semi-color-primary)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
color: 'var(--semi-color-text-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('配置有效的 io.net API Key')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作链接区域 */}
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div
|
|
||||||
onClick={handleGoToSettings}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'var(--semi-color-primary)',
|
|
||||||
background: 'var(--semi-color-fill-0)',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--semi-color-fill-1)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
||||||
e.currentTarget.style.boxShadow =
|
|
||||||
'0 2px 8px rgba(0, 0, 0, 0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'var(--semi-color-fill-0)';
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
{t('前往设置页面')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部提示 */}
|
|
||||||
<Text
|
|
||||||
type='tertiary'
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('配置完成后刷新页面即可使用模型部署功能')}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionLoading || (connectionOk === null && !connectionError)) {
|
|
||||||
return (
|
|
||||||
<div className='mt-[60px] px-2'>
|
|
||||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
|
||||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
|
||||||
<Text type='secondary'>{t('正在检查 io.net 连接...')}</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionOk === false) {
|
|
||||||
const isExpired = connectionError?.type === 'expired';
|
|
||||||
const title = isExpired ? t('接口密钥已过期') : t('无法连接 io.net');
|
|
||||||
const description = isExpired
|
|
||||||
? t('当前 API 密钥已过期,请在设置中更新。')
|
|
||||||
: t('当前配置无法连接到 io.net。');
|
|
||||||
const detail = connectionError?.message || '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='mt-[60px] px-4'
|
|
||||||
style={{
|
|
||||||
minHeight: 'calc(100vh - 60px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '600px',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '0 20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
padding: '60px 40px',
|
|
||||||
borderRadius: '16px',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: '32px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '120px',
|
|
||||||
height: '120px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
|
|
||||||
border: '3px solid rgba(var(--semi-red-4), 0.3)',
|
|
||||||
marginBottom: '24px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WifiOff size={56} color='var(--semi-color-danger)' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<Title
|
|
||||||
heading={2}
|
|
||||||
style={{
|
|
||||||
color: 'var(--semi-color-text-0)',
|
|
||||||
margin: '0 0 12px 0',
|
|
||||||
fontSize: '28px',
|
|
||||||
fontWeight: '700',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
color: 'var(--semi-color-text-1)',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
{detail ? (
|
|
||||||
<Text
|
|
||||||
type='tertiary'
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
display: 'block',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{detail}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type='primary'
|
|
||||||
icon={<Settings size={18} />}
|
|
||||||
onClick={handleGoToSettings}
|
|
||||||
>
|
|
||||||
{t('前往设置')}
|
|
||||||
</Button>
|
|
||||||
{onRetry ? (
|
|
||||||
<Button type='tertiary' onClick={onRetry}>
|
|
||||||
{t('重试连接')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeploymentAccessGuard;
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui';
|
|
||||||
import { MessageSquare, Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CustomInputRender from './CustomInputRender';
|
|
||||||
|
|
||||||
const ChatArea = ({
|
|
||||||
chatRef,
|
|
||||||
message,
|
|
||||||
inputs,
|
|
||||||
styleState,
|
|
||||||
showDebugPanel,
|
|
||||||
roleInfo,
|
|
||||||
onMessageSend,
|
|
||||||
onMessageCopy,
|
|
||||||
onMessageReset,
|
|
||||||
onMessageDelete,
|
|
||||||
onStopGenerator,
|
|
||||||
onClearMessages,
|
|
||||||
onToggleDebugPanel,
|
|
||||||
renderCustomChatContent,
|
|
||||||
renderChatBoxAction,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const renderInputArea = React.useCallback((props) => {
|
|
||||||
return <CustomInputRender {...props} />;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className='h-full'
|
|
||||||
bordered={false}
|
|
||||||
bodyStyle={{
|
|
||||||
padding: 0,
|
|
||||||
height: 'calc(100vh - 66px)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 聊天头部 */}
|
|
||||||
{styleState.isMobile ? (
|
|
||||||
<div className='pt-4'></div>
|
|
||||||
) : (
|
|
||||||
<div className='px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
<div className='w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center'>
|
|
||||||
<MessageSquare size={20} className='text-white' />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Title heading={5} className='!text-white mb-0'>
|
|
||||||
{t('AI 对话')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text className='!text-white/80 text-sm hidden sm:inline'>
|
|
||||||
{inputs.model || t('选择模型开始对话')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Button
|
|
||||||
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
onClick={onToggleDebugPanel}
|
|
||||||
theme='borderless'
|
|
||||||
type='primary'
|
|
||||||
size='small'
|
|
||||||
className='!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10'
|
|
||||||
>
|
|
||||||
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 聊天内容区域 */}
|
|
||||||
<div className='flex-1 overflow-hidden'>
|
|
||||||
<Chat
|
|
||||||
ref={chatRef}
|
|
||||||
chatBoxRenderConfig={{
|
|
||||||
renderChatBoxContent: renderCustomChatContent,
|
|
||||||
renderChatBoxAction: renderChatBoxAction,
|
|
||||||
renderChatBoxTitle: () => null,
|
|
||||||
}}
|
|
||||||
renderInputArea={renderInputArea}
|
|
||||||
roleConfig={roleInfo}
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
chats={message}
|
|
||||||
onMessageSend={onMessageSend}
|
|
||||||
onMessageCopy={onMessageCopy}
|
|
||||||
onMessageReset={onMessageReset}
|
|
||||||
onMessageDelete={onMessageDelete}
|
|
||||||
showClearContext
|
|
||||||
showStopGenerate
|
|
||||||
onStopGenerator={onStopGenerator}
|
|
||||||
onClear={onClearMessages}
|
|
||||||
className='h-full'
|
|
||||||
placeholder={t('请输入您的问题...')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatArea;
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
|
||||||
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { copy } from '../../helpers';
|
|
||||||
|
|
||||||
const PERFORMANCE_CONFIG = {
|
|
||||||
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
|
|
||||||
PREVIEW_LENGTH: 5000, // 预览长度
|
|
||||||
VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeThemeStyles = {
|
|
||||||
container: {
|
|
||||||
backgroundColor: '#1e1e1e',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #3c3c3c',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
height: '100%',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'auto',
|
|
||||||
padding: '16px',
|
|
||||||
margin: 0,
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
wordBreak: 'normal',
|
|
||||||
background: '#1e1e1e',
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
zIndex: 10,
|
|
||||||
backgroundColor: 'rgba(45, 45, 45, 0.9)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
borderRadius: '6px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
},
|
|
||||||
actionButtonHover: {
|
|
||||||
backgroundColor: 'rgba(60, 60, 60, 0.95)',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
},
|
|
||||||
noContent: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
color: '#666',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
backgroundColor: 'var(--semi-color-fill-0)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
},
|
|
||||||
performanceWarning: {
|
|
||||||
padding: '8px 12px',
|
|
||||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
|
||||||
border: '1px solid rgba(255, 193, 7, 0.3)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
color: '#ffc107',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeHtml = (str) => {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
};
|
|
||||||
|
|
||||||
const highlightJson = (str) => {
|
|
||||||
const tokenRegex =
|
|
||||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
|
|
||||||
|
|
||||||
let result = '';
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = tokenRegex.exec(str)) !== null) {
|
|
||||||
// Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
|
|
||||||
result += escapeHtml(str.slice(lastIndex, match.index));
|
|
||||||
|
|
||||||
const token = match[0];
|
|
||||||
let color = '#b5cea8';
|
|
||||||
if (/^"/.test(token)) {
|
|
||||||
color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
|
|
||||||
} else if (/true|false|null/.test(token)) {
|
|
||||||
color = '#569cd6';
|
|
||||||
}
|
|
||||||
// Escape token content before wrapping in span
|
|
||||||
result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
|
|
||||||
lastIndex = tokenRegex.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape remaining text
|
|
||||||
result += escapeHtml(str.slice(lastIndex));
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&)+)/g;
|
|
||||||
|
|
||||||
const linkifyHtml = (html) => {
|
|
||||||
const parts = html.split(/(<[^>]+>)/g);
|
|
||||||
return parts
|
|
||||||
.map((part) => {
|
|
||||||
if (part.startsWith('<')) return part;
|
|
||||||
return part.replace(
|
|
||||||
linkRegex,
|
|
||||||
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isJsonLike = (content, language) => {
|
|
||||||
if (language === 'json') return true;
|
|
||||||
const trimmed = content.trim();
|
|
||||||
return (
|
|
||||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
||||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatContent = (content) => {
|
|
||||||
if (!content) return '';
|
|
||||||
|
|
||||||
if (typeof content === 'object') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(content, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return String(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
return JSON.stringify(parsed, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CodeViewer = ({ content, title, language = 'json' }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [isHoveringCopy, setIsHoveringCopy] = useState(false);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
const formattedContent = useMemo(() => formatContent(content), [content]);
|
|
||||||
|
|
||||||
const contentMetrics = useMemo(() => {
|
|
||||||
const length = formattedContent.length;
|
|
||||||
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
|
|
||||||
const isVeryLarge =
|
|
||||||
length >
|
|
||||||
PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
|
|
||||||
PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
|
||||||
return { length, isLarge, isVeryLarge };
|
|
||||||
}, [formattedContent.length]);
|
|
||||||
|
|
||||||
const displayContent = useMemo(() => {
|
|
||||||
if (!contentMetrics.isLarge || isExpanded) {
|
|
||||||
return formattedContent;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
|
||||||
'\n\n// ... 内容被截断以提升性能 ...'
|
|
||||||
);
|
|
||||||
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
|
|
||||||
|
|
||||||
const highlightedContent = useMemo(() => {
|
|
||||||
if (contentMetrics.isVeryLarge && !isExpanded) {
|
|
||||||
return escapeHtml(displayContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isJsonLike(displayContent, language)) {
|
|
||||||
return highlightJson(displayContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return escapeHtml(displayContent);
|
|
||||||
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
|
|
||||||
|
|
||||||
const renderedContent = useMemo(() => {
|
|
||||||
return linkifyHtml(highlightedContent);
|
|
||||||
}, [highlightedContent]);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const textToCopy =
|
|
||||||
typeof content === 'object' && content !== null
|
|
||||||
? JSON.stringify(content, null, 2)
|
|
||||||
: content;
|
|
||||||
|
|
||||||
const success = await copy(textToCopy);
|
|
||||||
setCopied(true);
|
|
||||||
Toast.success(t('已复制到剪贴板'));
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
throw new Error('Copy operation failed');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
Toast.error(t('复制失败'));
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
}
|
|
||||||
}, [content, t]);
|
|
||||||
|
|
||||||
const handleToggleExpand = useCallback(() => {
|
|
||||||
if (contentMetrics.isVeryLarge && !isExpanded) {
|
|
||||||
setIsProcessing(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsExpanded(true);
|
|
||||||
setIsProcessing(false);
|
|
||||||
}, 100);
|
|
||||||
} else {
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
}
|
|
||||||
}, [isExpanded, contentMetrics.isVeryLarge]);
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
const placeholderText =
|
|
||||||
{
|
|
||||||
preview: t('正在构造请求体预览...'),
|
|
||||||
request: t('暂无请求数据'),
|
|
||||||
response: t('暂无响应数据'),
|
|
||||||
}[title] || t('暂无数据');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={codeThemeStyles.noContent}>
|
|
||||||
<span>{placeholderText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const warningTop = contentMetrics.isLarge ? '52px' : '12px';
|
|
||||||
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={codeThemeStyles.container} className='h-full'>
|
|
||||||
{/* 性能警告 */}
|
|
||||||
{contentMetrics.isLarge && (
|
|
||||||
<div style={codeThemeStyles.performanceWarning}>
|
|
||||||
<span>⚡</span>
|
|
||||||
<span>
|
|
||||||
{contentMetrics.isVeryLarge
|
|
||||||
? t('内容较大,已启用性能优化模式')
|
|
||||||
: t('内容较大,部分功能可能受限')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 复制按钮 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...codeThemeStyles.actionButton,
|
|
||||||
...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
|
|
||||||
top: warningTop,
|
|
||||||
right: '12px',
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setIsHoveringCopy(true)}
|
|
||||||
onMouseLeave={() => setIsHoveringCopy(false)}
|
|
||||||
>
|
|
||||||
<Tooltip content={copied ? t('已复制') : t('复制代码')}>
|
|
||||||
<Button
|
|
||||||
icon={<Copy size={14} />}
|
|
||||||
onClick={handleCopy}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: copied ? '#4ade80' : '#d4d4d4',
|
|
||||||
padding: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 代码内容 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...codeThemeStyles.content,
|
|
||||||
paddingTop: contentPadding,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
|
||||||
className='model-settings-scroll'
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '200px',
|
|
||||||
color: '#888',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
border: '2px solid #444',
|
|
||||||
borderTop: '2px solid #888',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
marginRight: '8px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{t('正在处理大内容...')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: renderedContent }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开/收起按钮 */}
|
|
||||||
{contentMetrics.isLarge && !isProcessing && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...codeThemeStyles.actionButton,
|
|
||||||
bottom: '12px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
|
||||||
}
|
|
||||||
onClick={handleToggleExpand}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: '#d4d4d4',
|
|
||||||
padding: '6px 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? t('收起') : t('展开')}
|
|
||||||
{!isExpanded && (
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
|
|
||||||
>
|
|
||||||
(+
|
|
||||||
{Math.round(
|
|
||||||
(contentMetrics.length -
|
|
||||||
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
|
|
||||||
1000,
|
|
||||||
)}
|
|
||||||
K)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CodeViewer;
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
|
||||||
import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui';
|
|
||||||
import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
exportConfig,
|
|
||||||
importConfig,
|
|
||||||
clearConfig,
|
|
||||||
hasStoredConfig,
|
|
||||||
getConfigTimestamp,
|
|
||||||
} from './configStorage';
|
|
||||||
|
|
||||||
const ConfigManager = ({
|
|
||||||
currentConfig,
|
|
||||||
onConfigImport,
|
|
||||||
onConfigReset,
|
|
||||||
styleState,
|
|
||||||
messages,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
try {
|
|
||||||
// 在导出前先保存当前配置,确保导出的是最新内容
|
|
||||||
const configWithTimestamp = {
|
|
||||||
...currentConfig,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
localStorage.setItem(
|
|
||||||
'playground_config',
|
|
||||||
JSON.stringify(configWithTimestamp),
|
|
||||||
);
|
|
||||||
|
|
||||||
exportConfig(currentConfig, messages);
|
|
||||||
Toast.success({
|
|
||||||
content: t('配置已导出到下载文件夹'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error({
|
|
||||||
content: t('导出配置失败: ') + error.message,
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = async (event) => {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importedConfig = await importConfig(file);
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('确认导入配置'),
|
|
||||||
content: t('导入的配置将覆盖当前设置,是否继续?'),
|
|
||||||
okText: t('确定导入'),
|
|
||||||
cancelText: t('取消'),
|
|
||||||
onOk: () => {
|
|
||||||
onConfigImport(importedConfig);
|
|
||||||
Toast.success({
|
|
||||||
content: t('配置导入成功'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error({
|
|
||||||
content: t('导入配置失败: ') + error.message,
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// 重置文件输入,允许重复选择同一文件
|
|
||||||
event.target.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('重置配置'),
|
|
||||||
content: t(
|
|
||||||
'将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?',
|
|
||||||
),
|
|
||||||
okText: t('确定重置'),
|
|
||||||
cancelText: t('取消'),
|
|
||||||
okButtonProps: {
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
onOk: () => {
|
|
||||||
// 询问是否同时重置消息
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('重置选项'),
|
|
||||||
content: t(
|
|
||||||
'是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。',
|
|
||||||
),
|
|
||||||
okText: t('同时重置消息'),
|
|
||||||
cancelText: t('仅重置配置'),
|
|
||||||
okButtonProps: {
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
onOk: () => {
|
|
||||||
clearConfig();
|
|
||||||
onConfigReset({ resetMessages: true });
|
|
||||||
Toast.success({
|
|
||||||
content: t('配置和消息已全部重置'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
clearConfig();
|
|
||||||
onConfigReset({ resetMessages: false });
|
|
||||||
Toast.success({
|
|
||||||
content: t('配置已重置,对话消息已保留'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfigStatus = () => {
|
|
||||||
if (hasStoredConfig()) {
|
|
||||||
const timestamp = getConfigTimestamp();
|
|
||||||
if (timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return t('上次保存: ') + date.toLocaleString();
|
|
||||||
}
|
|
||||||
return t('已有保存的配置');
|
|
||||||
}
|
|
||||||
return t('暂无保存的配置');
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownItems = [
|
|
||||||
{
|
|
||||||
node: 'item',
|
|
||||||
name: 'export',
|
|
||||||
onClick: handleExport,
|
|
||||||
children: (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Download size={14} />
|
|
||||||
{t('导出配置')}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
node: 'item',
|
|
||||||
name: 'import',
|
|
||||||
onClick: handleImportClick,
|
|
||||||
children: (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Upload size={14} />
|
|
||||||
{t('导入配置')}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
node: 'divider',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
node: 'item',
|
|
||||||
name: 'reset',
|
|
||||||
onClick: handleReset,
|
|
||||||
children: (
|
|
||||||
<div className='flex items-center gap-2 text-red-600'>
|
|
||||||
<RotateCcw size={14} />
|
|
||||||
{t('重置配置')}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (styleState.isMobile) {
|
|
||||||
// 移动端显示简化的下拉菜单
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dropdown
|
|
||||||
trigger='click'
|
|
||||||
position='bottomLeft'
|
|
||||||
showTick
|
|
||||||
menu={dropdownItems}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<Settings2 size={14} />}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
className='!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50'
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept='.json'
|
|
||||||
onChange={handleFileChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 桌面端显示紧凑的按钮组
|
|
||||||
return (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{/* 配置状态信息和重置按钮 */}
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<Typography.Text className='text-xs text-gray-500'>
|
|
||||||
{getConfigStatus()}
|
|
||||||
</Typography.Text>
|
|
||||||
<Button
|
|
||||||
icon={<RotateCcw size={12} />}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
type='danger'
|
|
||||||
onClick={handleReset}
|
|
||||||
className='!rounded-full !text-xs !px-2'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 导出和导入按钮 */}
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
icon={<Download size={12} />}
|
|
||||||
size='small'
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
onClick={handleExport}
|
|
||||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
|
||||||
>
|
|
||||||
{t('导出')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={<Upload size={12} />}
|
|
||||||
size='small'
|
|
||||||
theme='outline'
|
|
||||||
type='primary'
|
|
||||||
onClick={handleImportClick}
|
|
||||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
|
||||||
>
|
|
||||||
{t('导入')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept='.json'
|
|
||||||
onChange={handleFileChange}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConfigManager;
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { Toast } from '@douyinfe/semi-ui';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { usePlayground } from '../../contexts/PlaygroundContext';
|
|
||||||
|
|
||||||
const CustomInputRender = (props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { onPasteImage, imageEnabled } = usePlayground();
|
|
||||||
const { detailProps } = props;
|
|
||||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
|
|
||||||
detailProps;
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
|
||||||
async (e) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
|
|
||||||
if (item.type.indexOf('image') !== -1) {
|
|
||||||
e.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
if (!imageEnabled) {
|
|
||||||
Toast.warning({
|
|
||||||
content: t('请先在设置中启用图片功能'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const base64 = event.target.result;
|
|
||||||
|
|
||||||
if (onPasteImage) {
|
|
||||||
onPasteImage(base64);
|
|
||||||
Toast.success({
|
|
||||||
content: t('图片已添加'),
|
|
||||||
duration: 2,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Toast.error({
|
|
||||||
content: t('无法添加图片'),
|
|
||||||
duration: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
console.error('Failed to read image file:', reader.error);
|
|
||||||
Toast.error({
|
|
||||||
content: t('粘贴图片失败'),
|
|
||||||
duration: 2,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to paste image:', error);
|
|
||||||
Toast.error({
|
|
||||||
content: t('粘贴图片失败'),
|
|
||||||
duration: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onPasteImage, imageEnabled, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.addEventListener('paste', handlePaste);
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('paste', handlePaste);
|
|
||||||
};
|
|
||||||
}, [handlePaste]);
|
|
||||||
|
|
||||||
// 清空按钮
|
|
||||||
const styledClearNode = clearContextNode
|
|
||||||
? React.cloneElement(clearContextNode, {
|
|
||||||
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
|
|
||||||
style: {
|
|
||||||
...clearContextNode.props.style,
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
minWidth: '32px',
|
|
||||||
padding: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 发送按钮
|
|
||||||
const styledSendNode = React.cloneElement(sendNode, {
|
|
||||||
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
|
|
||||||
style: {
|
|
||||||
...sendNode.props.style,
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
minWidth: '32px',
|
|
||||||
padding: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='p-2 sm:p-4' ref={containerRef}>
|
|
||||||
<div
|
|
||||||
className='flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow'
|
|
||||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
|
||||||
onClick={onClick}
|
|
||||||
title={t('支持 Ctrl+V 粘贴图片')}
|
|
||||||
>
|
|
||||||
{/* 清空对话按钮 - 左边 */}
|
|
||||||
{styledClearNode}
|
|
||||||
<div className='flex-1'>{inputNode}</div>
|
|
||||||
{/* 发送按钮 - 右边 */}
|
|
||||||
{styledSendNode}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomInputRender;
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
TextArea,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Switch,
|
|
||||||
Banner,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const CustomRequestEditor = ({
|
|
||||||
customRequestMode,
|
|
||||||
customRequestBody,
|
|
||||||
onCustomRequestModeChange,
|
|
||||||
onCustomRequestBodyChange,
|
|
||||||
defaultPayload,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isValid, setIsValid] = useState(true);
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
const [localValue, setLocalValue] = useState(customRequestBody || '');
|
|
||||||
|
|
||||||
// 当切换到自定义模式时,用默认payload初始化
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
customRequestMode &&
|
|
||||||
(!customRequestBody || customRequestBody.trim() === '')
|
|
||||||
) {
|
|
||||||
const defaultJson = defaultPayload
|
|
||||||
? JSON.stringify(defaultPayload, null, 2)
|
|
||||||
: '';
|
|
||||||
setLocalValue(defaultJson);
|
|
||||||
onCustomRequestBodyChange(defaultJson);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
customRequestMode,
|
|
||||||
defaultPayload,
|
|
||||||
customRequestBody,
|
|
||||||
onCustomRequestBodyChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 同步外部传入的customRequestBody到本地状态
|
|
||||||
useEffect(() => {
|
|
||||||
if (customRequestBody !== localValue) {
|
|
||||||
setLocalValue(customRequestBody || '');
|
|
||||||
validateJson(customRequestBody || '');
|
|
||||||
}
|
|
||||||
}, [customRequestBody]);
|
|
||||||
|
|
||||||
// 验证JSON格式
|
|
||||||
const validateJson = (value) => {
|
|
||||||
if (!value.trim()) {
|
|
||||||
setIsValid(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JSON.parse(value);
|
|
||||||
setIsValid(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
setIsValid(false);
|
|
||||||
setErrorMessage(`${t('JSON格式错误')}: ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValueChange = (value) => {
|
|
||||||
setLocalValue(value);
|
|
||||||
validateJson(value);
|
|
||||||
// 始终保存用户输入,让预览逻辑处理JSON解析错误
|
|
||||||
onCustomRequestBodyChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModeToggle = (enabled) => {
|
|
||||||
onCustomRequestModeChange(enabled);
|
|
||||||
if (enabled && defaultPayload) {
|
|
||||||
const defaultJson = JSON.stringify(defaultPayload, null, 2);
|
|
||||||
setLocalValue(defaultJson);
|
|
||||||
onCustomRequestBodyChange(defaultJson);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatJson = () => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(localValue);
|
|
||||||
const formatted = JSON.stringify(parsed, null, 2);
|
|
||||||
setLocalValue(formatted);
|
|
||||||
onCustomRequestBodyChange(formatted);
|
|
||||||
setIsValid(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
} catch (error) {
|
|
||||||
// 如果格式化失败,保持原样
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{/* 自定义模式开关 */}
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Code size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('自定义请求体模式')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={customRequestMode}
|
|
||||||
onChange={handleModeToggle}
|
|
||||||
checkedText={t('开')}
|
|
||||||
uncheckedText={t('关')}
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{customRequestMode && (
|
|
||||||
<>
|
|
||||||
{/* 提示信息 */}
|
|
||||||
<Banner
|
|
||||||
type='warning'
|
|
||||||
description={t(
|
|
||||||
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
|
|
||||||
)}
|
|
||||||
icon={<AlertTriangle size={16} />}
|
|
||||||
className='!rounded-lg'
|
|
||||||
closeIcon={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* JSON编辑器 */}
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('请求体 JSON')}
|
|
||||||
</Typography.Text>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{isValid ? (
|
|
||||||
<div className='flex items-center gap-1 text-green-600'>
|
|
||||||
<Check size={14} />
|
|
||||||
<Typography.Text className='text-xs'>
|
|
||||||
{t('格式正确')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='flex items-center gap-1 text-red-600'>
|
|
||||||
<X size={14} />
|
|
||||||
<Typography.Text className='text-xs'>
|
|
||||||
{t('格式错误')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<Edit size={14} />}
|
|
||||||
onClick={formatJson}
|
|
||||||
disabled={!isValid}
|
|
||||||
className='!rounded-lg'
|
|
||||||
>
|
|
||||||
{t('格式化')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
value={localValue}
|
|
||||||
onChange={handleValueChange}
|
|
||||||
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
|
|
||||||
autosize={{ minRows: 8, maxRows: 20 }}
|
|
||||||
className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isValid && errorMessage && (
|
|
||||||
<Typography.Text type='danger' className='text-xs mt-1 block'>
|
|
||||||
{errorMessage}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
|
||||||
{t(
|
|
||||||
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
|
|
||||||
)}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomRequestEditor;
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Typography,
|
|
||||||
Tabs,
|
|
||||||
TabPane,
|
|
||||||
Button,
|
|
||||||
Dropdown,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CodeViewer from './CodeViewer';
|
|
||||||
import SSEViewer from './SSEViewer';
|
|
||||||
|
|
||||||
const DebugPanel = ({
|
|
||||||
debugData,
|
|
||||||
activeDebugTab,
|
|
||||||
onActiveDebugTabChange,
|
|
||||||
styleState,
|
|
||||||
onCloseDebugPanel,
|
|
||||||
customRequestMode,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [activeKey, setActiveKey] = useState(activeDebugTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveKey(activeDebugTab);
|
|
||||||
}, [activeDebugTab]);
|
|
||||||
|
|
||||||
const handleTabChange = (key) => {
|
|
||||||
setActiveKey(key);
|
|
||||||
onActiveDebugTabChange(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
|
|
||||||
const style = {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
margin: '0 12px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: '100%',
|
|
||||||
background: 'rgba(var(--semi-grey-1), 1)',
|
|
||||||
color: 'var(--semi-color-text)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
render={
|
|
||||||
<Dropdown.Menu>
|
|
||||||
{items.map((item) => {
|
|
||||||
return (
|
|
||||||
<Dropdown.Item
|
|
||||||
key={item.itemKey}
|
|
||||||
onClick={() => handleTabChange(item.itemKey)}
|
|
||||||
>
|
|
||||||
{item.tab}
|
|
||||||
</Dropdown.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{pos === 'start' ? (
|
|
||||||
<div style={style} onClick={handleArrowClick}>
|
|
||||||
←
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={style} onClick={handleArrowClick}>
|
|
||||||
→
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className='h-full flex flex-col'
|
|
||||||
bordered={false}
|
|
||||||
bodyStyle={{
|
|
||||||
padding: styleState.isMobile ? '16px' : '24px',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3'>
|
|
||||||
<Code size={20} className='text-white' />
|
|
||||||
</div>
|
|
||||||
<Typography.Title heading={5} className='mb-0'>
|
|
||||||
{t('调试信息')}
|
|
||||||
</Typography.Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{styleState.isMobile && onCloseDebugPanel && (
|
|
||||||
<Button
|
|
||||||
icon={<X size={16} />}
|
|
||||||
onClick={onCloseDebugPanel}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
className='!rounded-lg'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex-1 overflow-hidden debug-panel'>
|
|
||||||
<Tabs
|
|
||||||
renderArrow={renderArrow}
|
|
||||||
type='card'
|
|
||||||
collapsible
|
|
||||||
className='h-full'
|
|
||||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
||||||
activeKey={activeKey}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Eye size={16} />
|
|
||||||
{t('预览请求体')}
|
|
||||||
{customRequestMode && (
|
|
||||||
<span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'>
|
|
||||||
自定义
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
itemKey='preview'
|
|
||||||
>
|
|
||||||
<CodeViewer
|
|
||||||
content={debugData.previewRequest}
|
|
||||||
title='preview'
|
|
||||||
language='json'
|
|
||||||
/>
|
|
||||||
</TabPane>
|
|
||||||
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Send size={16} />
|
|
||||||
{t('实际请求体')}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
itemKey='request'
|
|
||||||
>
|
|
||||||
<CodeViewer
|
|
||||||
content={debugData.request}
|
|
||||||
title='request'
|
|
||||||
language='json'
|
|
||||||
/>
|
|
||||||
</TabPane>
|
|
||||||
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Zap size={16} />
|
|
||||||
{t('响应')}
|
|
||||||
{debugData.sseMessages && debugData.sseMessages.length > 0 && (
|
|
||||||
<span className='px-1.5 py-0.5 text-xs bg-blue-100 text-blue-600 rounded-full'>
|
|
||||||
SSE ({debugData.sseMessages.length})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
itemKey='response'
|
|
||||||
>
|
|
||||||
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
|
|
||||||
<SSEViewer sseData={debugData.sseMessages} title='response' />
|
|
||||||
) : (
|
|
||||||
<CodeViewer
|
|
||||||
content={debugData.response}
|
|
||||||
title='response'
|
|
||||||
language='json'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabPane>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between mt-4 pt-4 flex-shrink-0'>
|
|
||||||
{(debugData.timestamp || debugData.previewTimestamp) && (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Clock size={14} className='text-gray-500' />
|
|
||||||
<Typography.Text className='text-xs text-gray-500'>
|
|
||||||
{activeKey === 'preview' && debugData.previewTimestamp
|
|
||||||
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
|
|
||||||
: debugData.timestamp
|
|
||||||
? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
|
|
||||||
: ''}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DebugPanel;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
|
||||||
import { Settings, Eye, EyeOff } from 'lucide-react';
|
|
||||||
|
|
||||||
const FloatingButtons = ({
|
|
||||||
styleState,
|
|
||||||
showSettings,
|
|
||||||
showDebugPanel,
|
|
||||||
onToggleSettings,
|
|
||||||
onToggleDebugPanel,
|
|
||||||
}) => {
|
|
||||||
if (!styleState.isMobile) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 设置按钮 */}
|
|
||||||
{!showSettings && (
|
|
||||||
<Button
|
|
||||||
icon={<Settings size={18} />}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
right: 16,
|
|
||||||
bottom: 90,
|
|
||||||
zIndex: 1000,
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: 0,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
|
||||||
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
|
|
||||||
}}
|
|
||||||
onClick={onToggleSettings}
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
className='lg:hidden'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 调试按钮 */}
|
|
||||||
{!showSettings && (
|
|
||||||
<Button
|
|
||||||
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
onClick={onToggleDebugPanel}
|
|
||||||
theme='solid'
|
|
||||||
type={showDebugPanel ? 'danger' : 'primary'}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
right: 16,
|
|
||||||
bottom: 140,
|
|
||||||
zIndex: 1000,
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: 0,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
|
||||||
background: showDebugPanel
|
|
||||||
? 'linear-gradient(to right, #e11d48, #be123c)'
|
|
||||||
: 'linear-gradient(to right, #4f46e5, #6366f1)',
|
|
||||||
}}
|
|
||||||
className='lg:hidden'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FloatingButtons;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
|
||||||
import { IconFile } from '@douyinfe/semi-icons';
|
|
||||||
import { FileText, Plus, X, Image } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const ImageUrlInput = ({
|
|
||||||
imageUrls,
|
|
||||||
imageEnabled,
|
|
||||||
onImageUrlsChange,
|
|
||||||
onImageEnabledChange,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const handleAddImageUrl = () => {
|
|
||||||
const newUrls = [...imageUrls, ''];
|
|
||||||
onImageUrlsChange(newUrls);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateImageUrl = (index, value) => {
|
|
||||||
const newUrls = [...imageUrls];
|
|
||||||
newUrls[index] = value;
|
|
||||||
onImageUrlsChange(newUrls);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveImageUrl = (index) => {
|
|
||||||
const newUrls = imageUrls.filter((_, i) => i !== index);
|
|
||||||
onImageUrlsChange(newUrls);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={disabled ? 'opacity-50' : ''}>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Image
|
|
||||||
size={16}
|
|
||||||
className={
|
|
||||||
imageEnabled && !disabled ? 'text-blue-500' : 'text-gray-400'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('图片地址')}
|
|
||||||
</Typography.Text>
|
|
||||||
{disabled && (
|
|
||||||
<Typography.Text className='text-xs text-orange-600'>
|
|
||||||
({t('已在自定义模式中忽略')})
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Switch
|
|
||||||
checked={imageEnabled}
|
|
||||||
onChange={onImageEnabledChange}
|
|
||||||
checkedText={t('启用')}
|
|
||||||
uncheckedText={t('停用')}
|
|
||||||
size='small'
|
|
||||||
className='flex-shrink-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={<Plus size={14} />}
|
|
||||||
size='small'
|
|
||||||
theme='solid'
|
|
||||||
type='primary'
|
|
||||||
onClick={handleAddImageUrl}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={!imageEnabled || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!imageEnabled ? (
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
|
||||||
{disabled
|
|
||||||
? t('图片功能在自定义请求体模式下不可用')
|
|
||||||
: t('启用后可添加图片URL进行多模态对话')}
|
|
||||||
</Typography.Text>
|
|
||||||
) : imageUrls.length === 0 ? (
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
|
||||||
{disabled
|
|
||||||
? t('图片功能在自定义请求体模式下不可用')
|
|
||||||
: t('点击 + 按钮添加图片URL进行多模态对话')}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
|
||||||
{t('已添加')} {imageUrls.length} {t('张图片')}
|
|
||||||
{disabled ? ` (${t('自定义模式下不可用')})` : ''}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
{imageUrls.map((url, index) => (
|
|
||||||
<div key={index} className='flex items-center gap-2'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<Input
|
|
||||||
placeholder={`https://example.com/image${index + 1}.jpg`}
|
|
||||||
value={url}
|
|
||||||
onChange={(value) => handleUpdateImageUrl(index, value)}
|
|
||||||
className='!rounded-lg'
|
|
||||||
size='small'
|
|
||||||
prefix={<IconFile size='small' />}
|
|
||||||
disabled={!imageEnabled || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
icon={<X size={12} />}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
type='danger'
|
|
||||||
onClick={() => handleRemoveImageUrl(index)}
|
|
||||||
className='!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0'
|
|
||||||
disabled={!imageEnabled || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageUrlInput;
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
|
||||||
import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const MessageActions = ({
|
|
||||||
message,
|
|
||||||
styleState,
|
|
||||||
onMessageReset,
|
|
||||||
onMessageCopy,
|
|
||||||
onMessageDelete,
|
|
||||||
onRoleToggle,
|
|
||||||
onMessageEdit,
|
|
||||||
isAnyMessageGenerating = false,
|
|
||||||
isEditing = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
message.status === 'loading' || message.status === 'incomplete';
|
|
||||||
const shouldDisableActions = isAnyMessageGenerating || isEditing;
|
|
||||||
const canToggleRole =
|
|
||||||
message.role === 'assistant' || message.role === 'system';
|
|
||||||
const canEdit =
|
|
||||||
!isLoading &&
|
|
||||||
message.content &&
|
|
||||||
typeof onMessageEdit === 'function' &&
|
|
||||||
!isEditing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex items-center gap-0.5'>
|
|
||||||
{!isLoading && (
|
|
||||||
<Tooltip
|
|
||||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')}
|
|
||||||
position='top'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
|
|
||||||
onClick={() => !shouldDisableActions && onMessageReset(message)}
|
|
||||||
disabled={shouldDisableActions}
|
|
||||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
|
||||||
aria-label={t('重试')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message.content && (
|
|
||||||
<Tooltip content={t('复制')} position='top'>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
|
|
||||||
onClick={() => onMessageCopy(message)}
|
|
||||||
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
|
||||||
aria-label={t('复制')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<Tooltip
|
|
||||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')}
|
|
||||||
position='top'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
|
|
||||||
onClick={() => !shouldDisableActions && onMessageEdit(message)}
|
|
||||||
disabled={shouldDisableActions}
|
|
||||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-yellow-600 hover:!bg-yellow-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
|
||||||
aria-label={t('编辑')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canToggleRole && !isLoading && (
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
shouldDisableActions
|
|
||||||
? t('操作暂时被禁用')
|
|
||||||
: message.role === 'assistant'
|
|
||||||
? t('切换为System角色')
|
|
||||||
: t('切换为Assistant角色')
|
|
||||||
}
|
|
||||||
position='top'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
|
|
||||||
onClick={() =>
|
|
||||||
!shouldDisableActions && onRoleToggle && onRoleToggle(message)
|
|
||||||
}
|
|
||||||
disabled={shouldDisableActions}
|
|
||||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
|
||||||
aria-label={
|
|
||||||
message.role === 'assistant'
|
|
||||||
? t('切换为System角色')
|
|
||||||
: t('切换为Assistant角色')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && (
|
|
||||||
<Tooltip
|
|
||||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')}
|
|
||||||
position='top'
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
|
|
||||||
onClick={() => !shouldDisableActions && onMessageDelete(message)}
|
|
||||||
disabled={shouldDisableActions}
|
|
||||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
|
||||||
aria-label={t('删除')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageActions;
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
|
||||||
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
|
|
||||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
|
||||||
import ThinkingContent from './ThinkingContent';
|
|
||||||
import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { isAdmin } from '../../helpers/utils';
|
|
||||||
|
|
||||||
const MessageContent = ({
|
|
||||||
message,
|
|
||||||
className,
|
|
||||||
styleState,
|
|
||||||
onToggleReasoningExpansion,
|
|
||||||
isEditing = false,
|
|
||||||
onEditSave,
|
|
||||||
onEditCancel,
|
|
||||||
editValue,
|
|
||||||
onEditValueChange,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const previousContentLengthRef = useRef(0);
|
|
||||||
const lastContentRef = useRef('');
|
|
||||||
|
|
||||||
const isThinkingStatus =
|
|
||||||
message.status === 'loading' || message.status === 'incomplete';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isThinkingStatus) {
|
|
||||||
previousContentLengthRef.current = 0;
|
|
||||||
lastContentRef.current = '';
|
|
||||||
}
|
|
||||||
}, [isThinkingStatus]);
|
|
||||||
|
|
||||||
if (message.status === 'error') {
|
|
||||||
let errorText;
|
|
||||||
|
|
||||||
if (Array.isArray(message.content)) {
|
|
||||||
const textContent = message.content.find((item) => item.type === 'text');
|
|
||||||
errorText =
|
|
||||||
textContent && textContent.text && typeof textContent.text === 'string'
|
|
||||||
? textContent.text
|
|
||||||
: t('请求发生错误');
|
|
||||||
} else if (typeof message.content === 'string') {
|
|
||||||
errorText = message.content;
|
|
||||||
} else {
|
|
||||||
errorText = t('请求发生错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.errorCode === 'model_price_error') {
|
|
||||||
return (
|
|
||||||
<div className={`${className}`}>
|
|
||||||
<div
|
|
||||||
className='rounded-lg p-3 space-y-2'
|
|
||||||
style={{
|
|
||||||
background: 'var(--semi-color-bg-0)',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
|
|
||||||
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
|
|
||||||
{t('模型价格未配置')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Typography.Paragraph
|
|
||||||
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
|
|
||||||
style={{ wordBreak: 'break-word' }}
|
|
||||||
>
|
|
||||||
{errorText}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
{isAdmin() && (
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
theme='light'
|
|
||||||
type='warning'
|
|
||||||
icon={<Settings size={14} />}
|
|
||||||
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
|
|
||||||
>
|
|
||||||
{t('前往设置')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${className}`}>
|
|
||||||
<Typography.Text className='text-white'>{errorText}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentExtractedThinkingContent = null;
|
|
||||||
let currentDisplayableFinalContent = '';
|
|
||||||
let thinkingSource = null;
|
|
||||||
|
|
||||||
const getTextContent = (content) => {
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
const textItem = content.find((item) => item.type === 'text');
|
|
||||||
return textItem && textItem.text && typeof textItem.text === 'string'
|
|
||||||
? textItem.text
|
|
||||||
: '';
|
|
||||||
} else if (typeof content === 'string') {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
currentDisplayableFinalContent = getTextContent(message.content);
|
|
||||||
|
|
||||||
if (message.role === 'assistant') {
|
|
||||||
let baseContentForDisplay = getTextContent(message.content);
|
|
||||||
let combinedThinkingContent = '';
|
|
||||||
|
|
||||||
if (message.reasoningContent) {
|
|
||||||
combinedThinkingContent = message.reasoningContent;
|
|
||||||
thinkingSource = 'reasoningContent';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseContentForDisplay.includes('<think>')) {
|
|
||||||
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
|
|
||||||
let match;
|
|
||||||
let thoughtsFromPairedTags = [];
|
|
||||||
let replyParts = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
|
|
||||||
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
|
|
||||||
replyParts.push(
|
|
||||||
baseContentForDisplay.substring(lastIndex, match.index),
|
|
||||||
);
|
|
||||||
thoughtsFromPairedTags.push(match[1]);
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
replyParts.push(baseContentForDisplay.substring(lastIndex));
|
|
||||||
|
|
||||||
if (thoughtsFromPairedTags.length > 0) {
|
|
||||||
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
|
|
||||||
if (combinedThinkingContent) {
|
|
||||||
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
|
|
||||||
} else {
|
|
||||||
combinedThinkingContent = pairedThoughtsStr;
|
|
||||||
}
|
|
||||||
thinkingSource = thinkingSource
|
|
||||||
? thinkingSource + ' & <think> tags'
|
|
||||||
: '<think> tags';
|
|
||||||
}
|
|
||||||
|
|
||||||
baseContentForDisplay = replyParts.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isThinkingStatus) {
|
|
||||||
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
|
|
||||||
if (lastOpenThinkIndex !== -1) {
|
|
||||||
const fragmentAfterLastOpen =
|
|
||||||
baseContentForDisplay.substring(lastOpenThinkIndex);
|
|
||||||
if (!fragmentAfterLastOpen.includes('</think>')) {
|
|
||||||
const unclosedThought = fragmentAfterLastOpen
|
|
||||||
.substring('<think>'.length)
|
|
||||||
.trim();
|
|
||||||
if (unclosedThought) {
|
|
||||||
if (combinedThinkingContent) {
|
|
||||||
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
|
|
||||||
} else {
|
|
||||||
combinedThinkingContent = unclosedThought;
|
|
||||||
}
|
|
||||||
thinkingSource = thinkingSource
|
|
||||||
? thinkingSource + ' + streaming <think>'
|
|
||||||
: 'streaming <think>';
|
|
||||||
}
|
|
||||||
baseContentForDisplay = baseContentForDisplay.substring(
|
|
||||||
0,
|
|
||||||
lastOpenThinkIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentExtractedThinkingContent = combinedThinkingContent || null;
|
|
||||||
currentDisplayableFinalContent = baseContentForDisplay
|
|
||||||
.replace(/<\/?think>/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalExtractedThinkingContent = currentExtractedThinkingContent;
|
|
||||||
const finalDisplayableFinalContent = currentDisplayableFinalContent;
|
|
||||||
|
|
||||||
if (
|
|
||||||
message.role === 'assistant' &&
|
|
||||||
isThinkingStatus &&
|
|
||||||
!finalExtractedThinkingContent &&
|
|
||||||
(!finalDisplayableFinalContent ||
|
|
||||||
finalDisplayableFinalContent.trim() === '')
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}
|
|
||||||
>
|
|
||||||
<div className='w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg'>
|
|
||||||
<Loader2
|
|
||||||
className='animate-spin text-white'
|
|
||||||
size={styleState.isMobile ? 16 : 20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{message.role === 'system' && (
|
|
||||||
<div className='mb-2 sm:mb-4'>
|
|
||||||
<div
|
|
||||||
className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg'
|
|
||||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
|
||||||
>
|
|
||||||
<div className='w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm'>
|
|
||||||
<Typography.Text className='text-white text-xs font-bold'>
|
|
||||||
S
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Typography.Text className='text-amber-700 text-xs sm:text-sm font-medium'>
|
|
||||||
{t('系统消息')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{message.role === 'assistant' && (
|
|
||||||
<ThinkingContent
|
|
||||||
message={message}
|
|
||||||
finalExtractedThinkingContent={finalExtractedThinkingContent}
|
|
||||||
thinkingSource={thinkingSource}
|
|
||||||
styleState={styleState}
|
|
||||||
onToggleReasoningExpansion={onToggleReasoningExpansion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
<TextArea
|
|
||||||
value={editValue}
|
|
||||||
onChange={(value) => onEditValueChange(value)}
|
|
||||||
placeholder={t('请输入消息内容...')}
|
|
||||||
autosize={{ minRows: 3, maxRows: 12 }}
|
|
||||||
style={{
|
|
||||||
resize: 'vertical',
|
|
||||||
fontSize: styleState.isMobile ? '14px' : '15px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
}}
|
|
||||||
className='!border-blue-200 focus:!border-blue-400 !bg-blue-50/50'
|
|
||||||
/>
|
|
||||||
<div className='flex items-center gap-2 w-full'>
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='danger'
|
|
||||||
theme='light'
|
|
||||||
icon={<X size={14} />}
|
|
||||||
onClick={onEditCancel}
|
|
||||||
className='flex-1'
|
|
||||||
>
|
|
||||||
{t('取消')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='warning'
|
|
||||||
theme='solid'
|
|
||||||
icon={<Check size={14} />}
|
|
||||||
onClick={onEditSave}
|
|
||||||
disabled={!editValue || editValue.trim() === ''}
|
|
||||||
className='flex-1'
|
|
||||||
>
|
|
||||||
{t('保存')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
if (Array.isArray(message.content)) {
|
|
||||||
const textContent = message.content.find(
|
|
||||||
(item) => item.type === 'text',
|
|
||||||
);
|
|
||||||
const imageContents = message.content.filter(
|
|
||||||
(item) => item.type === 'image_url',
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{imageContents.length > 0 && (
|
|
||||||
<div className='mb-3 space-y-2'>
|
|
||||||
{imageContents.map((imgItem, index) => (
|
|
||||||
<div key={index} className='max-w-sm'>
|
|
||||||
<img
|
|
||||||
src={imgItem.image_url.url}
|
|
||||||
alt={`用户上传的图片 ${index + 1}`}
|
|
||||||
className='rounded-lg max-w-full h-auto shadow-sm border'
|
|
||||||
style={{ maxHeight: '300px' }}
|
|
||||||
onError={(e) => {
|
|
||||||
e.target.style.display = 'none';
|
|
||||||
e.target.nextSibling.style.display = 'block';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200'
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
>
|
|
||||||
图片加载失败: {imgItem.image_url.url}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{textContent &&
|
|
||||||
textContent.text &&
|
|
||||||
typeof textContent.text === 'string' &&
|
|
||||||
textContent.text.trim() !== '' && (
|
|
||||||
<div
|
|
||||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={textContent.text}
|
|
||||||
className={
|
|
||||||
message.role === 'user' ? 'user-message' : ''
|
|
||||||
}
|
|
||||||
animated={false}
|
|
||||||
previousContentLength={0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof message.content === 'string') {
|
|
||||||
if (message.role === 'assistant') {
|
|
||||||
if (
|
|
||||||
finalDisplayableFinalContent &&
|
|
||||||
finalDisplayableFinalContent.trim() !== ''
|
|
||||||
) {
|
|
||||||
// 获取上一次的内容长度
|
|
||||||
let prevLength = 0;
|
|
||||||
if (isThinkingStatus && lastContentRef.current) {
|
|
||||||
// 只有当前内容包含上一次内容时,才使用上一次的长度
|
|
||||||
if (
|
|
||||||
finalDisplayableFinalContent.startsWith(
|
|
||||||
lastContentRef.current,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
prevLength = lastContentRef.current.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新最后内容的引用
|
|
||||||
if (isThinkingStatus) {
|
|
||||||
lastContentRef.current = finalDisplayableFinalContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm'>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={finalDisplayableFinalContent}
|
|
||||||
className=''
|
|
||||||
animated={isThinkingStatus}
|
|
||||||
previousContentLength={prevLength}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={message.content}
|
|
||||||
className={message.role === 'user' ? 'user-message' : ''}
|
|
||||||
animated={false}
|
|
||||||
previousContentLength={0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MessageContent;
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import MessageContent from './MessageContent';
|
|
||||||
import MessageActions from './MessageActions';
|
|
||||||
import SettingsPanel from './SettingsPanel';
|
|
||||||
import DebugPanel from './DebugPanel';
|
|
||||||
|
|
||||||
// 优化的消息内容组件
|
|
||||||
export const OptimizedMessageContent = React.memo(
|
|
||||||
MessageContent,
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
// 只有这些属性变化时才重新渲染
|
|
||||||
return (
|
|
||||||
prevProps.message.id === nextProps.message.id &&
|
|
||||||
prevProps.message.content === nextProps.message.content &&
|
|
||||||
prevProps.message.status === nextProps.message.status &&
|
|
||||||
prevProps.message.role === nextProps.message.role &&
|
|
||||||
prevProps.message.reasoningContent ===
|
|
||||||
nextProps.message.reasoningContent &&
|
|
||||||
prevProps.message.isReasoningExpanded ===
|
|
||||||
nextProps.message.isReasoningExpanded &&
|
|
||||||
prevProps.isEditing === nextProps.isEditing &&
|
|
||||||
prevProps.editValue === nextProps.editValue &&
|
|
||||||
prevProps.styleState.isMobile === nextProps.styleState.isMobile
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 优化的消息操作组件
|
|
||||||
export const OptimizedMessageActions = React.memo(
|
|
||||||
MessageActions,
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return (
|
|
||||||
prevProps.message.id === nextProps.message.id &&
|
|
||||||
prevProps.message.role === nextProps.message.role &&
|
|
||||||
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
|
|
||||||
prevProps.isEditing === nextProps.isEditing &&
|
|
||||||
prevProps.onMessageReset === nextProps.onMessageReset
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 优化的设置面板组件
|
|
||||||
export const OptimizedSettingsPanel = React.memo(
|
|
||||||
SettingsPanel,
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return (
|
|
||||||
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
|
|
||||||
JSON.stringify(prevProps.parameterEnabled) ===
|
|
||||||
JSON.stringify(nextProps.parameterEnabled) &&
|
|
||||||
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
|
|
||||||
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
|
|
||||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
|
||||||
prevProps.customRequestBody === nextProps.customRequestBody &&
|
|
||||||
prevProps.showDebugPanel === nextProps.showDebugPanel &&
|
|
||||||
prevProps.showSettings === nextProps.showSettings &&
|
|
||||||
JSON.stringify(prevProps.previewPayload) ===
|
|
||||||
JSON.stringify(nextProps.previewPayload) &&
|
|
||||||
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 优化的调试面板组件
|
|
||||||
export const OptimizedDebugPanel = React.memo(
|
|
||||||
DebugPanel,
|
|
||||||
(prevProps, nextProps) => {
|
|
||||||
return (
|
|
||||||
prevProps.show === nextProps.show &&
|
|
||||||
prevProps.activeTab === nextProps.activeTab &&
|
|
||||||
JSON.stringify(prevProps.debugData) ===
|
|
||||||
JSON.stringify(nextProps.debugData) &&
|
|
||||||
JSON.stringify(prevProps.previewPayload) ===
|
|
||||||
JSON.stringify(nextProps.previewPayload) &&
|
|
||||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
|
||||||
prevProps.showDebugPanel === nextProps.showDebugPanel
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Slider,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Tag,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Hash,
|
|
||||||
Thermometer,
|
|
||||||
Target,
|
|
||||||
Repeat,
|
|
||||||
Ban,
|
|
||||||
Shuffle,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const ParameterControl = ({
|
|
||||||
inputs,
|
|
||||||
parameterEnabled,
|
|
||||||
onInputChange,
|
|
||||||
onParameterToggle,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Temperature */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Thermometer size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Temperature
|
|
||||||
</Typography.Text>
|
|
||||||
<Tag size='small' shape='circle'>
|
|
||||||
{inputs.temperature}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
parameterEnabled.temperature ? (
|
|
||||||
<Check size={10} />
|
|
||||||
) : (
|
|
||||||
<X size={10} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => onParameterToggle('temperature')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
|
||||||
{t('控制输出的随机性和创造性')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Slider
|
|
||||||
step={0.1}
|
|
||||||
min={0.1}
|
|
||||||
max={1}
|
|
||||||
value={inputs.temperature}
|
|
||||||
onChange={(value) => onInputChange('temperature', value)}
|
|
||||||
className='mt-2'
|
|
||||||
disabled={!parameterEnabled.temperature || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top P */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Target size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Top P
|
|
||||||
</Typography.Text>
|
|
||||||
<Tag size='small' shape='circle'>
|
|
||||||
{inputs.top_p}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />
|
|
||||||
}
|
|
||||||
onClick={() => onParameterToggle('top_p')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
|
||||||
{t('核采样,控制词汇选择的多样性')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Slider
|
|
||||||
step={0.1}
|
|
||||||
min={0.1}
|
|
||||||
max={1}
|
|
||||||
value={inputs.top_p}
|
|
||||||
onChange={(value) => onInputChange('top_p', value)}
|
|
||||||
className='mt-2'
|
|
||||||
disabled={!parameterEnabled.top_p || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frequency Penalty */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Repeat size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Frequency Penalty
|
|
||||||
</Typography.Text>
|
|
||||||
<Tag size='small' shape='circle'>
|
|
||||||
{inputs.frequency_penalty}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
parameterEnabled.frequency_penalty ? (
|
|
||||||
<Check size={10} />
|
|
||||||
) : (
|
|
||||||
<X size={10} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => onParameterToggle('frequency_penalty')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
|
||||||
{t('频率惩罚,减少重复词汇的出现')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Slider
|
|
||||||
step={0.1}
|
|
||||||
min={-2}
|
|
||||||
max={2}
|
|
||||||
value={inputs.frequency_penalty}
|
|
||||||
onChange={(value) => onInputChange('frequency_penalty', value)}
|
|
||||||
className='mt-2'
|
|
||||||
disabled={!parameterEnabled.frequency_penalty || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Presence Penalty */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Ban size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Presence Penalty
|
|
||||||
</Typography.Text>
|
|
||||||
<Tag size='small' shape='circle'>
|
|
||||||
{inputs.presence_penalty}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
parameterEnabled.presence_penalty ? (
|
|
||||||
<Check size={10} />
|
|
||||||
) : (
|
|
||||||
<X size={10} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => onParameterToggle('presence_penalty')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
|
||||||
{t('存在惩罚,鼓励讨论新话题')}
|
|
||||||
</Typography.Text>
|
|
||||||
<Slider
|
|
||||||
step={0.1}
|
|
||||||
min={-2}
|
|
||||||
max={2}
|
|
||||||
value={inputs.presence_penalty}
|
|
||||||
onChange={(value) => onInputChange('presence_penalty', value)}
|
|
||||||
className='mt-2'
|
|
||||||
disabled={!parameterEnabled.presence_penalty || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MaxTokens */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Hash size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Max Tokens
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={
|
|
||||||
parameterEnabled.max_tokens ? (
|
|
||||||
<Check size={10} />
|
|
||||||
) : (
|
|
||||||
<X size={10} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => onParameterToggle('max_tokens')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<InputNumber
|
|
||||||
placeholder='MaxTokens'
|
|
||||||
name='max_tokens'
|
|
||||||
value={inputs.max_tokens}
|
|
||||||
onNumberChange={(value) => onInputChange('max_tokens', value)}
|
|
||||||
min={0}
|
|
||||||
precision={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
disabled={!parameterEnabled.max_tokens || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Seed */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Shuffle size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
Seed
|
|
||||||
</Typography.Text>
|
|
||||||
<Typography.Text className='text-xs text-gray-400'>
|
|
||||||
({t('可选,用于复现结果')})
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
|
|
||||||
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
|
|
||||||
size='small'
|
|
||||||
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
|
|
||||||
onClick={() => onParameterToggle('seed')}
|
|
||||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder={t('随机种子 (留空为随机)')}
|
|
||||||
name='seed'
|
|
||||||
autoComplete='new-password'
|
|
||||||
value={inputs.seed || ''}
|
|
||||||
onChange={(value) =>
|
|
||||||
onInputChange('seed', value === '' ? null : value)
|
|
||||||
}
|
|
||||||
className='!rounded-lg'
|
|
||||||
disabled={!parameterEnabled.seed || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ParameterControl;
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Tooltip,
|
|
||||||
Toast,
|
|
||||||
Collapse,
|
|
||||||
Badge,
|
|
||||||
Typography,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import {
|
|
||||||
Copy,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Zap,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { copy } from '../../helpers';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SSEViewer component for displaying Server-Sent Events in an interactive format
|
|
||||||
* @param {Object} props - Component props
|
|
||||||
* @param {Array} props.sseData - Array of SSE messages to display
|
|
||||||
* @returns {JSX.Element} Rendered SSE viewer component
|
|
||||||
*/
|
|
||||||
const SSEViewer = ({ sseData }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expandedKeys, setExpandedKeys] = useState([]);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const parsedSSEData = useMemo(() => {
|
|
||||||
if (!sseData || !Array.isArray(sseData)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return sseData.map((item, index) => {
|
|
||||||
let parsed = null;
|
|
||||||
let error = null;
|
|
||||||
let isDone = false;
|
|
||||||
|
|
||||||
if (item === '[DONE]') {
|
|
||||||
isDone = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
|
||||||
} catch (e) {
|
|
||||||
error = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
raw: item,
|
|
||||||
parsed,
|
|
||||||
error,
|
|
||||||
isDone,
|
|
||||||
key: `sse-${index}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [sseData]);
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
const total = parsedSSEData.length;
|
|
||||||
const errors = parsedSSEData.filter((item) => item.error).length;
|
|
||||||
const done = parsedSSEData.filter((item) => item.isDone).length;
|
|
||||||
const valid = total - errors - done;
|
|
||||||
|
|
||||||
return { total, errors, done, valid };
|
|
||||||
}, [parsedSSEData]);
|
|
||||||
|
|
||||||
const handleToggleAll = useCallback(() => {
|
|
||||||
setExpandedKeys((prev) => {
|
|
||||||
if (prev.length === parsedSSEData.length) {
|
|
||||||
return [];
|
|
||||||
} else {
|
|
||||||
return parsedSSEData.map((item) => item.key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [parsedSSEData]);
|
|
||||||
|
|
||||||
const handleCopyAll = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const allData = parsedSSEData
|
|
||||||
.map((item) =>
|
|
||||||
item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
|
|
||||||
)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
await copy(allData);
|
|
||||||
setCopied(true);
|
|
||||||
Toast.success(t('已复制全部数据'));
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
Toast.error(t('复制失败'));
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
}
|
|
||||||
}, [parsedSSEData, t]);
|
|
||||||
|
|
||||||
const handleCopySingle = useCallback(
|
|
||||||
async (item) => {
|
|
||||||
try {
|
|
||||||
const textToCopy = item.parsed
|
|
||||||
? JSON.stringify(item.parsed, null, 2)
|
|
||||||
: item.raw;
|
|
||||||
await copy(textToCopy);
|
|
||||||
Toast.success(t('已复制'));
|
|
||||||
} catch (err) {
|
|
||||||
Toast.error(t('复制失败'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSSEItem = (item) => {
|
|
||||||
if (item.isDone) {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg'>
|
|
||||||
<CheckCircle size={16} className='text-green-600' />
|
|
||||||
<Typography.Text className='text-green-600 font-medium'>
|
|
||||||
{t('流式响应完成')} [DONE]
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.error) {
|
|
||||||
return (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div className='flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg'>
|
|
||||||
<XCircle size={16} className='text-red-600' />
|
|
||||||
<Typography.Text className='text-red-600'>
|
|
||||||
{t('解析错误')}: {item.error}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div className='p-3 bg-gray-100 dark:bg-gray-800 rounded-lg font-mono text-xs overflow-auto'>
|
|
||||||
<pre>{item.raw}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{/* JSON 格式化显示 */}
|
|
||||||
<div className='relative'>
|
|
||||||
<pre className='p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-xs font-mono leading-relaxed'>
|
|
||||||
{JSON.stringify(item.parsed, null, 2)}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
icon={<Copy size={12} />}
|
|
||||||
size='small'
|
|
||||||
theme='borderless'
|
|
||||||
onClick={() => handleCopySingle(item)}
|
|
||||||
className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 关键信息摘要 */}
|
|
||||||
{item.parsed?.choices?.[0] && (
|
|
||||||
<div className='flex flex-wrap gap-2 text-xs'>
|
|
||||||
{item.parsed.choices[0].delta?.content && (
|
|
||||||
<Badge
|
|
||||||
count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
|
|
||||||
type='primary'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.parsed.choices[0].delta?.reasoning_content && (
|
|
||||||
<Badge count={t('有 Reasoning')} type='warning' />
|
|
||||||
)}
|
|
||||||
{item.parsed.choices[0].finish_reason && (
|
|
||||||
<Badge
|
|
||||||
count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
|
|
||||||
type='success'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.parsed.usage && (
|
|
||||||
<Badge
|
|
||||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
|
||||||
type='tertiary'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!parsedSSEData || parsedSSEData.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center justify-center h-full min-h-[200px] text-gray-500'>
|
|
||||||
<span>{t('暂无SSE响应数据')}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='h-full flex flex-col bg-gray-50 dark:bg-gray-900/50 rounded-lg'>
|
|
||||||
{/* 头部工具栏 */}
|
|
||||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
<Zap size={16} className='text-blue-500' />
|
|
||||||
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
|
|
||||||
<Badge count={stats.total} type='primary' />
|
|
||||||
{stats.errors > 0 && (
|
|
||||||
<Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Tooltip content={t('复制全部')}>
|
|
||||||
<Button
|
|
||||||
icon={<Copy size={14} />}
|
|
||||||
size='small'
|
|
||||||
onClick={handleCopyAll}
|
|
||||||
theme='borderless'
|
|
||||||
>
|
|
||||||
{copied ? t('已复制') : t('复制全部')}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
expandedKeys.length === parsedSSEData.length
|
|
||||||
? t('全部收起')
|
|
||||||
: t('全部展开')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
expandedKeys.length === parsedSSEData.length ? (
|
|
||||||
<ChevronUp size={14} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
size='small'
|
|
||||||
onClick={handleToggleAll}
|
|
||||||
theme='borderless'
|
|
||||||
>
|
|
||||||
{expandedKeys.length === parsedSSEData.length
|
|
||||||
? t('收起')
|
|
||||||
: t('展开')}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSE 数据列表 */}
|
|
||||||
<div className='flex-1 overflow-auto p-4'>
|
|
||||||
<Collapse
|
|
||||||
activeKey={expandedKeys}
|
|
||||||
onChange={setExpandedKeys}
|
|
||||||
accordion={false}
|
|
||||||
className='bg-white dark:bg-gray-800 rounded-lg'
|
|
||||||
>
|
|
||||||
{parsedSSEData.map((item) => (
|
|
||||||
<Collapse.Panel
|
|
||||||
key={item.key}
|
|
||||||
header={
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Badge count={`#${item.index + 1}`} type='tertiary' />
|
|
||||||
{item.isDone ? (
|
|
||||||
<span className='text-green-600 font-medium'>[DONE]</span>
|
|
||||||
) : item.error ? (
|
|
||||||
<span className='text-red-600'>{t('解析错误')}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className='text-gray-600'>
|
|
||||||
{item.parsed?.id ||
|
|
||||||
item.parsed?.object ||
|
|
||||||
t('SSE 事件')}
|
|
||||||
</span>
|
|
||||||
{item.parsed?.choices?.[0]?.delta && (
|
|
||||||
<span className='text-xs text-gray-400'>
|
|
||||||
•{' '}
|
|
||||||
{Object.keys(item.parsed.choices[0].delta)
|
|
||||||
.filter((k) => item.parsed.choices[0].delta[k])
|
|
||||||
.join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderSSEItem(item)}
|
|
||||||
</Collapse.Panel>
|
|
||||||
))}
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SSEViewer;
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
|
||||||
import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { renderGroupOption, selectFilter } from '../../helpers';
|
|
||||||
import ParameterControl from './ParameterControl';
|
|
||||||
import ImageUrlInput from './ImageUrlInput';
|
|
||||||
import ConfigManager from './ConfigManager';
|
|
||||||
import CustomRequestEditor from './CustomRequestEditor';
|
|
||||||
|
|
||||||
const SettingsPanel = ({
|
|
||||||
inputs,
|
|
||||||
parameterEnabled,
|
|
||||||
models,
|
|
||||||
groups,
|
|
||||||
styleState,
|
|
||||||
showDebugPanel,
|
|
||||||
customRequestMode,
|
|
||||||
customRequestBody,
|
|
||||||
onInputChange,
|
|
||||||
onParameterToggle,
|
|
||||||
onCloseSettings,
|
|
||||||
onConfigImport,
|
|
||||||
onConfigReset,
|
|
||||||
onCustomRequestModeChange,
|
|
||||||
onCustomRequestBodyChange,
|
|
||||||
previewPayload,
|
|
||||||
messages,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const currentConfig = {
|
|
||||||
inputs,
|
|
||||||
parameterEnabled,
|
|
||||||
showDebugPanel,
|
|
||||||
customRequestMode,
|
|
||||||
customRequestBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className='h-full flex flex-col'
|
|
||||||
bordered={false}
|
|
||||||
bodyStyle={{
|
|
||||||
padding: styleState.isMobile ? '16px' : '24px',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 标题区域 - 与调试面板保持一致 */}
|
|
||||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3'>
|
|
||||||
<Settings size={20} className='text-white' />
|
|
||||||
</div>
|
|
||||||
<Typography.Title heading={5} className='mb-0'>
|
|
||||||
{t('模型配置')}
|
|
||||||
</Typography.Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{styleState.isMobile && onCloseSettings && (
|
|
||||||
<Button
|
|
||||||
icon={<X size={16} />}
|
|
||||||
onClick={onCloseSettings}
|
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
size='small'
|
|
||||||
className='!rounded-lg'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 移动端配置管理 */}
|
|
||||||
{styleState.isMobile && (
|
|
||||||
<div className='mb-4 flex-shrink-0'>
|
|
||||||
<ConfigManager
|
|
||||||
currentConfig={currentConfig}
|
|
||||||
onConfigImport={onConfigImport}
|
|
||||||
onConfigReset={onConfigReset}
|
|
||||||
styleState={{ ...styleState, isMobile: false }}
|
|
||||||
messages={messages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll'>
|
|
||||||
{/* 自定义请求体编辑器 */}
|
|
||||||
<CustomRequestEditor
|
|
||||||
customRequestMode={customRequestMode}
|
|
||||||
customRequestBody={customRequestBody}
|
|
||||||
onCustomRequestModeChange={onCustomRequestModeChange}
|
|
||||||
onCustomRequestBodyChange={onCustomRequestBodyChange}
|
|
||||||
defaultPayload={previewPayload}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 分组选择 */}
|
|
||||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
|
||||||
<div className='flex items-center gap-2 mb-2'>
|
|
||||||
<Users size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('分组')}
|
|
||||||
</Typography.Text>
|
|
||||||
{customRequestMode && (
|
|
||||||
<Typography.Text className='text-xs text-orange-600'>
|
|
||||||
({t('已在自定义模式中忽略')})
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
placeholder={t('请选择分组')}
|
|
||||||
name='group'
|
|
||||||
required
|
|
||||||
selection
|
|
||||||
filter={selectFilter}
|
|
||||||
autoClearSearchValue={false}
|
|
||||||
onChange={(value) => onInputChange('group', value)}
|
|
||||||
value={inputs.group}
|
|
||||||
autoComplete='new-password'
|
|
||||||
optionList={groups}
|
|
||||||
renderOptionItem={renderGroupOption}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
|
||||||
className='!rounded-lg'
|
|
||||||
disabled={customRequestMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模型选择 */}
|
|
||||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
|
||||||
<div className='flex items-center gap-2 mb-2'>
|
|
||||||
<Sparkles size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('模型')}
|
|
||||||
</Typography.Text>
|
|
||||||
{customRequestMode && (
|
|
||||||
<Typography.Text className='text-xs text-orange-600'>
|
|
||||||
({t('已在自定义模式中忽略')})
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
placeholder={t('请选择模型')}
|
|
||||||
name='model'
|
|
||||||
required
|
|
||||||
selection
|
|
||||||
filter={selectFilter}
|
|
||||||
autoClearSearchValue={false}
|
|
||||||
onChange={(value) => onInputChange('model', value)}
|
|
||||||
value={inputs.model}
|
|
||||||
autoComplete='new-password'
|
|
||||||
optionList={models}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
|
||||||
className='!rounded-lg'
|
|
||||||
disabled={customRequestMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图片URL输入 */}
|
|
||||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
|
||||||
<ImageUrlInput
|
|
||||||
imageUrls={inputs.imageUrls}
|
|
||||||
imageEnabled={inputs.imageEnabled}
|
|
||||||
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
|
|
||||||
onImageEnabledChange={(enabled) =>
|
|
||||||
onInputChange('imageEnabled', enabled)
|
|
||||||
}
|
|
||||||
disabled={customRequestMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参数控制组件 */}
|
|
||||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
|
||||||
<ParameterControl
|
|
||||||
inputs={inputs}
|
|
||||||
parameterEnabled={parameterEnabled}
|
|
||||||
onInputChange={onInputChange}
|
|
||||||
onParameterToggle={onParameterToggle}
|
|
||||||
disabled={customRequestMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 流式输出开关 */}
|
|
||||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<ToggleLeft size={16} className='text-gray-500' />
|
|
||||||
<Typography.Text strong className='text-sm'>
|
|
||||||
{t('流式输出')}
|
|
||||||
</Typography.Text>
|
|
||||||
{customRequestMode && (
|
|
||||||
<Typography.Text className='text-xs text-orange-600'>
|
|
||||||
({t('已在自定义模式中忽略')})
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={inputs.stream}
|
|
||||||
onChange={(checked) => onInputChange('stream', checked)}
|
|
||||||
checkedText={t('开')}
|
|
||||||
uncheckedText={t('关')}
|
|
||||||
size='small'
|
|
||||||
disabled={customRequestMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 桌面端的配置管理放在底部 */}
|
|
||||||
{!styleState.isMobile && (
|
|
||||||
<div className='flex-shrink-0 pt-3'>
|
|
||||||
<ConfigManager
|
|
||||||
currentConfig={currentConfig}
|
|
||||||
onConfigImport={onConfigImport}
|
|
||||||
onConfigReset={onConfigReset}
|
|
||||||
styleState={styleState}
|
|
||||||
messages={messages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SettingsPanel;
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
|
||||||
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const ThinkingContent = ({
|
|
||||||
message,
|
|
||||||
finalExtractedThinkingContent,
|
|
||||||
thinkingSource,
|
|
||||||
styleState,
|
|
||||||
onToggleReasoningExpansion,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
const lastContentRef = useRef('');
|
|
||||||
|
|
||||||
const isThinkingStatus =
|
|
||||||
message.status === 'loading' || message.status === 'incomplete';
|
|
||||||
const headerText =
|
|
||||||
isThinkingStatus && !message.isThinkingComplete
|
|
||||||
? t('思考中...')
|
|
||||||
: t('思考过程');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
scrollRef.current &&
|
|
||||||
finalExtractedThinkingContent &&
|
|
||||||
message.isReasoningExpanded
|
|
||||||
) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isThinkingStatus) {
|
|
||||||
lastContentRef.current = '';
|
|
||||||
}
|
|
||||||
}, [isThinkingStatus]);
|
|
||||||
|
|
||||||
if (!finalExtractedThinkingContent) return null;
|
|
||||||
|
|
||||||
let prevLength = 0;
|
|
||||||
if (isThinkingStatus && lastContentRef.current) {
|
|
||||||
if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
|
|
||||||
prevLength = lastContentRef.current.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isThinkingStatus) {
|
|
||||||
lastContentRef.current = finalExtractedThinkingContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm'>
|
|
||||||
<div
|
|
||||||
className='flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all'
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleReasoningExpansion(message.id)}
|
|
||||||
>
|
|
||||||
<div className='absolute inset-0 overflow-hidden'>
|
|
||||||
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
|
|
||||||
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2 sm:gap-4 relative'>
|
|
||||||
<div className='w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg'>
|
|
||||||
<Brain
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
size={styleState.isMobile ? 12 : 16}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col'>
|
|
||||||
<Typography.Text
|
|
||||||
strong
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
className='text-sm sm:text-base'
|
|
||||||
>
|
|
||||||
{headerText}
|
|
||||||
</Typography.Text>
|
|
||||||
{thinkingSource && (
|
|
||||||
<Typography.Text
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
className='text-xs mt-0.5 opacity-80 hidden sm:block'
|
|
||||||
>
|
|
||||||
来源: {thinkingSource}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2 sm:gap-3 relative'>
|
|
||||||
{isThinkingStatus && !message.isThinkingComplete && (
|
|
||||||
<div className='flex items-center gap-1 sm:gap-2'>
|
|
||||||
<Loader2
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
className='animate-spin'
|
|
||||||
size={styleState.isMobile ? 14 : 18}
|
|
||||||
/>
|
|
||||||
<Typography.Text
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
className='text-xs sm:text-sm font-medium opacity-90'
|
|
||||||
>
|
|
||||||
思考中
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(!isThinkingStatus || message.isThinkingComplete) && (
|
|
||||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center'>
|
|
||||||
{message.isReasoningExpanded ? (
|
|
||||||
<ChevronUp
|
|
||||||
size={styleState.isMobile ? 12 : 16}
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronRight
|
|
||||||
size={styleState.isMobile ? 12 : 16}
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-500 ease-out ${
|
|
||||||
message.isReasoningExpanded
|
|
||||||
? 'max-h-96 opacity-100'
|
|
||||||
: 'max-h-0 opacity-0'
|
|
||||||
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
|
|
||||||
>
|
|
||||||
{message.isReasoningExpanded && (
|
|
||||||
<div className='p-3 sm:p-5 pt-2 sm:pt-4'>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className='bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll'
|
|
||||||
style={{
|
|
||||||
maxHeight: '200px',
|
|
||||||
scrollbarWidth: 'thin',
|
|
||||||
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm'>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={finalExtractedThinkingContent}
|
|
||||||
className=''
|
|
||||||
animated={isThinkingStatus}
|
|
||||||
previousContentLength={prevLength}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThinkingContent;
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2025 modelstoken
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact admin@modelstoken.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
STORAGE_KEYS,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
} from '../../constants/playground.constants';
|
|
||||||
|
|
||||||
const MESSAGES_STORAGE_KEY = 'playground_messages';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存配置到 localStorage
|
|
||||||
* @param {Object} config - 要保存的配置对象
|
|
||||||
*/
|
|
||||||
export const saveConfig = (config) => {
|
|
||||||
try {
|
|
||||||
const configToSave = {
|
|
||||||
...config,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存配置失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存消息到 localStorage
|
|
||||||
* @param {Array} messages - 要保存的消息数组
|
|
||||||
*/
|
|
||||||
export const saveMessages = (messages) => {
|
|
||||||
try {
|
|
||||||
const messagesToSave = {
|
|
||||||
messages,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存消息失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 localStorage 加载配置
|
|
||||||
* @returns {Object} 配置对象,如果不存在则返回默认配置
|
|
||||||
*/
|
|
||||||
export const loadConfig = () => {
|
|
||||||
try {
|
|
||||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
|
||||||
if (savedConfig) {
|
|
||||||
const parsedConfig = JSON.parse(savedConfig);
|
|
||||||
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
|
|
||||||
|
|
||||||
const mergedConfig = {
|
|
||||||
inputs: {
|
|
||||||
...DEFAULT_CONFIG.inputs,
|
|
||||||
...parsedConfig.inputs,
|
|
||||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
|
||||||
? parsedConfig?.inputs?.max_tokens
|
|
||||||
: parsedMaxTokens,
|
|
||||||
},
|
|
||||||
parameterEnabled: {
|
|
||||||
...DEFAULT_CONFIG.parameterEnabled,
|
|
||||||
...parsedConfig.parameterEnabled,
|
|
||||||
},
|
|
||||||
showDebugPanel:
|
|
||||||
parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
|
||||||
customRequestMode:
|
|
||||||
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
|
||||||
customRequestBody:
|
|
||||||
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
return mergedConfig;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载配置失败:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_CONFIG;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 localStorage 加载消息
|
|
||||||
* @returns {Array} 消息数组,如果不存在则返回 null
|
|
||||||
*/
|
|
||||||
export const loadMessages = () => {
|
|
||||||
try {
|
|
||||||
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
|
|
||||||
if (savedMessages) {
|
|
||||||
const parsedMessages = JSON.parse(savedMessages);
|
|
||||||
return parsedMessages.messages || null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载消息失败:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除保存的配置
|
|
||||||
*/
|
|
||||||
export const clearConfig = () => {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.CONFIG);
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清除配置失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除保存的消息
|
|
||||||
*/
|
|
||||||
export const clearMessages = () => {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清除消息失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否有保存的配置
|
|
||||||
* @returns {boolean} 是否存在保存的配置
|
|
||||||
*/
|
|
||||||
export const hasStoredConfig = () => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查配置失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取配置的最后保存时间
|
|
||||||
* @returns {string|null} 最后保存时间的 ISO 字符串
|
|
||||||
*/
|
|
||||||
export const getConfigTimestamp = () => {
|
|
||||||
try {
|
|
||||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
|
||||||
if (savedConfig) {
|
|
||||||
const parsedConfig = JSON.parse(savedConfig);
|
|
||||||
return parsedConfig.timestamp || null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配置时间戳失败:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出配置为 JSON 文件(包含消息)
|
|
||||||
* @param {Object} config - 要导出的配置
|
|
||||||
* @param {Array} messages - 要导出的消息
|
|
||||||
*/
|
|
||||||
export const exportConfig = (config, messages = null) => {
|
|
||||||
try {
|
|
||||||
const configToExport = {
|
|
||||||
...config,
|
|
||||||
messages: messages || loadMessages(), // 包含消息数据
|
|
||||||
exportTime: new Date().toISOString(),
|
|
||||||
version: '1.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataStr = JSON.stringify(configToExport, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = URL.createObjectURL(dataBlob);
|
|
||||||
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('导出配置失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从文件导入配置(包含消息)
|
|
||||||
* @param {File} file - 包含配置的 JSON 文件
|
|
||||||
* @returns {Promise<Object>} 导入的配置对象
|
|
||||||
*/
|
|
||||||
export const importConfig = (file) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const importedConfig = JSON.parse(e.target.result);
|
|
||||||
|
|
||||||
if (importedConfig.inputs && importedConfig.parameterEnabled) {
|
|
||||||
// 如果导入的配置包含消息,也一起导入
|
|
||||||
if (
|
|
||||||
importedConfig.messages &&
|
|
||||||
Array.isArray(importedConfig.messages)
|
|
||||||
) {
|
|
||||||
saveMessages(importedConfig.messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(importedConfig);
|
|
||||||
} else {
|
|
||||||
reject(new Error('配置文件格式无效'));
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
reject(new Error('解析配置文件失败: ' + parseError.message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
|
||||||
reader.readAsText(file);
|
|
||||||
} catch (error) {
|
|
||||||
reject(new Error('导入配置失败: ' + error.message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||