feat(web): support classic Rsbuild dev and build

- migrate the classic frontend from Vite to Rsbuild with JSX, Semi UI, proxy, and production build config.
- update make dev-web to run both default and classic frontends for local theme switching.
- fix classic public page height, footer, CORS proxy, error handling, and constant export warnings.
- update Dockerfile and release workflow to install from the web workspace root with the shared lockfile.
This commit is contained in:
QuentinHsu
2026-06-01 23:31:51 +08:00
parent 9a2e60dff2
commit 1e9ff8a0de
23 changed files with 302 additions and 260 deletions
-1
View File
@@ -24,6 +24,5 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+11 -10
View File
@@ -4,6 +4,7 @@
"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:",
@@ -14,6 +15,7 @@
"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",
@@ -21,8 +23,8 @@
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "catalog:",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
@@ -42,13 +44,13 @@
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"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": "vite preview",
"preview": "rsbuild preview",
"i18n:extract": "bunx i18next-cli extract",
"i18n:status": "bunx i18next-cli status",
"i18n:sync": "bunx i18next-cli sync",
@@ -73,20 +75,19 @@
]
},
"devDependencies": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@rsbuild/core": "^2.0.7",
"@rsbuild/plugin-react": "^2.0.0",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"code-inspector-plugin": "^1.3.3",
"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",
"vite": "^5.2.0"
"typescript": "4.4.2"
},
"prettier": {
"singleQuote": true,
+106
View File
@@ -0,0 +1,106 @@
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 -1
View File
@@ -947,7 +947,7 @@ const LoginForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<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'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<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'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<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'
+1 -1
View File
@@ -770,7 +770,7 @@ const RegisterForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<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'
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 显示加载状态
if (loading) {
return (
<div className='flex justify-center items-center min-h-screen'>
<div className='classic-page-fill flex justify-center items-center'>
<Spin size='large' />
</div>
);
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果没有内容,显示空状态
if (!content || content.trim() === '') {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
<Empty
title={t('管理员未设置' + title + '内容')}
image={
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果是 URL,显示链接卡片
if (isUrl(content)) {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
<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'>
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果是 HTML 内容,直接渲染
if (isHtmlContent(content)) {
return (
<div className='min-h-screen bg-gray-50'>
<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'>
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 其他内容统一使用 Markdown 渲染器
return (
<div className='min-h-screen bg-gray-50'>
<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'>
+10 -5
View File
@@ -71,6 +71,7 @@ const PageLayout = () => {
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
@@ -146,11 +147,11 @@ const PageLayout = () => {
return (
<Layout
className='app-layout'
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
overflow: isMobile ? 'visible' : 'hidden',
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
}}
>
<Header
@@ -171,9 +172,10 @@ const PageLayout = () => {
</Header>
<Layout
style={{
overflow: isMobile ? 'visible' : 'auto',
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
}}
>
{showSider && (
@@ -206,15 +208,18 @@ const PageLayout = () => {
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Content
className={isFixedLayout ? undefined : 'public-page-content'}
style={{
flex: '1 0 auto',
overflowY: isMobile ? 'visible' : 'hidden',
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>
+43 -9
View File
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './dashboard.constants';
export * from './playground.constants';
export * from './redemption.constants';
export * from './channel-affinity-template.constants';
export * from './billing.constants';
export {
CHANNEL_OPTIONS,
MODEL_FETCHABLE_CHANNEL_TYPES,
MODEL_TABLE_PAGE_SIZE,
} from './channel.constants';
export { userConstants } from './user.constants';
export { toastConstants } from './toast.constants';
export {
ITEMS_PER_PAGE,
DEFAULT_ENDPOINT,
TABLE_COMPACT_MODES_KEY,
API_ENDPOINTS,
TASK_ACTION_GENERATE,
TASK_ACTION_TEXT_GENERATE,
TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_REMIX_GENERATE,
} from './common.constant';
export {
REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS,
} from './redemption.constants';
export {
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CHANNEL_AFFINITY_RULE_TEMPLATES,
cloneChannelAffinityTemplate,
} from './channel-affinity-template.constants';
export {
BILLING_VARS,
BILLING_VAR_KEYS,
BILLING_PRICING_VARS,
BILLING_EXTRA_VARS,
BILLING_VAR_KEY_TO_FIELD,
BILLING_VAR_FIELD_TO_LABEL,
BILLING_VAR_FIELD_TO_SHORT_LABEL,
BILLING_CACHE_VAR_MAP,
BILLING_VAR_REGEX,
BILLING_CONDITION_VARS,
} from './billing.constants';
+2 -2
View File
@@ -94,7 +94,6 @@ import {
SiGitlab,
SiGoogle,
SiKeycloak,
SiLinkedin,
SiNextcloud,
SiNotion,
SiOkta,
@@ -106,6 +105,7 @@ import {
SiWechat,
SiX,
} from 'react-icons/si';
import { FaLinkedin } from 'react-icons/fa';
// 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) {
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
google: SiGoogle,
discord: SiDiscord,
facebook: SiFacebook,
linkedin: SiLinkedin,
linkedin: FaLinkedin,
x: SiX,
twitter: SiX,
slack: SiSlack,
+1 -1
View File
@@ -123,7 +123,7 @@ export function showError(error) {
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
switch (error.response?.status) {
case 401:
// 清除用户状态
localStorage.removeItem('user');
+28 -6
View File
@@ -31,18 +31,40 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
.app-layout {
min-height: 100vh;
min-height: 100dvh;
}
.app-layout {
.app-layout-fixed {
height: 100vh;
height: 100dvh;
}
.public-page-content {
display: flex;
flex-direction: column;
}
.classic-page-fill {
flex: 1 0 auto;
min-height: 100%;
}
.classic-home-page,
.classic-home-default {
display: flex;
flex-direction: column;
}
.classic-home-default {
flex: 1 0 auto;
}
.classic-home-hero {
flex: 1 0 auto;
}
.app-sider {
height: calc(100vh - 64px);
height: calc(100dvh - 64px);
+8 -3
View File
@@ -133,9 +133,9 @@ const About = () => {
);
return (
<div className='mt-[60px] px-2'>
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
{aboutLoaded && about === '' ? (
<div className='flex justify-center items-center h-screen p-8'>
<div className='flex flex-1 justify-center items-center p-8'>
<Empty
image={
<IllustrationConstruction style={{ width: 150, height: 150 }} />
@@ -156,7 +156,12 @@ const About = () => {
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
style={{
width: '100%',
flex: '1 1 auto',
minHeight: 0,
border: 'none',
}}
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const Forbidden = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
darkModeImage={
+6 -6
View File
@@ -149,20 +149,20 @@ const Home = () => {
}, [endpointItems.length]);
return (
<div className='w-full overflow-x-hidden'>
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={isMobile}
/>
{homePageContentLoaded && homePageContent === '' ? (
<div className='w-full overflow-x-hidden'>
<div className='classic-home-default w-full overflow-x-hidden'>
{/* Banner 部分 */}
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
{/* 背景模糊晕染球 */}
<div className='blur-ball blur-ball-indigo' />
<div className='blur-ball blur-ball-teal' />
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
{/* 居中内容区 */}
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
@@ -335,11 +335,11 @@ const Home = () => {
</div>
</div>
) : (
<div className='overflow-x-hidden w-full'>
<div className='classic-page-fill overflow-x-hidden w-full'>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent}
className='w-full h-screen border-none'
className='w-full h-full border-none'
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const NotFound = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
darkModeImage={
-107
View File
@@ -1,107 +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 react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
import path from 'path';
import { codeInspectorPlugin } from 'code-inspector-plugin';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
codeInspectorPlugin({
bundler: 'vite',
}),
{
name: 'treat-js-files-as-jsx',
async transform(code, id) {
if (!/src\/.*\.js$/.test(id)) {
return null;
}
// Use the exposed transform from vite, instead of directly
// transforming with esbuild
return transformWithEsbuild(code, id, {
loader: 'jsx',
jsx: 'automatic',
});
},
},
react(),
vitePluginSemi({
cssLayer: true,
}),
],
optimizeDeps: {
force: true,
esbuildOptions: {
loader: {
'.js': 'jsx',
'.json': 'json',
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'react-core': ['react', 'react-dom', 'react-router-dom'],
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
tools: ['axios', 'history', 'marked'],
'react-components': [
'react-dropzone',
'react-fireworks',
'react-telegram-login',
'react-toastify',
'react-turnstile',
],
i18n: [
'i18next',
'react-i18next',
'i18next-browser-languagedetector',
],
},
},
},
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/mj': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/pg': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});