Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	frontend/components/providers/title-updater.tsx
#	frontend/messages/fragments/admin.en.json
#	frontend/messages/fragments/admin.zh-CN.json
#	frontend/proxy.ts
This commit is contained in:
ryan
2026-09-03 09:16:22 +08:00
1065 changed files with 271858 additions and 9600 deletions
@@ -6,8 +6,8 @@ import { type VariantProps } from 'class-variance-authority';
import { buttonVariants } from '@/components/animate-ui/components/buttons/button';
import {
RippleButton as RippleButtonPrimitive,
RippleButtonRipples as RippleButtonRipplesPrimitive,
type RippleButtonProps as RippleButtonPrimitiveProps,
RippleButtonRipples as RippleButtonRipplesPrimitive,
type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/ripple';
import { cn } from '@/lib/utils';
@@ -1,7 +1,12 @@
'use client';
import * as React from 'react';
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
import {
type HTMLMotionProps,
type MotionValue,
isMotionComponent,
motion,
} from 'motion/react';
import { cn } from '@/lib/utils';
type AnyProps = Record<string, unknown>;
@@ -16,8 +21,7 @@ type WithAsChild<Base extends object> =
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
children?: React.ReactNode | MotionValue<string> | MotionValue<number>;
} & DOMMotionProps<T>;
function mergeRefs<T>(
@@ -63,21 +67,24 @@ function Slot<T extends HTMLElement = HTMLElement>({
ref,
...props
}: SlotProps<T>) {
const isValidChild = React.isValidElement(children);
const childrenType = isValidChild ? children.type : undefined;
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
isValidChild &&
typeof childrenType === 'object' &&
childrenType !== null &&
isMotionComponent(childrenType);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
? (childrenType as React.ElementType)
: motion.create(childrenType as React.ElementType),
[isAlreadyMotion, childrenType],
);
if (!React.isValidElement(children)) return null;
if (!isValidChild) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import { type SpringOptions, useMotionValue, useSpring } from 'motion/react';
import { useIsInView, type UseIsInViewOptions } from '@/hooks/use-is-in-view';
+1 -1
View File
@@ -46,7 +46,7 @@ interface AuthHeadingProps {
export function AuthHeading({
title,
description,
siteName = 'Wavelet',
siteName = 'OpenFlare',
}: AuthHeadingProps) {
return (
<header className='flex flex-col gap-6 [@media(max-height:700px)]:gap-3'>
+3 -3
View File
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
import { EyeIcon, EyeOffIcon } from 'lucide-react';
import { toast } from 'sonner';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/components/providers/auth-provider';
import { Button } from '@/components/ui/button';
@@ -19,7 +20,6 @@ import { OTPForm } from './otp-form';
import { AuthService } from '@/lib/services/auth';
import { ConfigService } from '@/lib/services/config';
import type { LoginRequest } from '@/lib/services/auth/types';
import { useTranslations } from 'next-intl';
import { safeRedirectTarget } from '@/lib/utils';
function persistRedirectTarget(
@@ -198,8 +198,8 @@ export function LoginForm({
};
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true);
configBool(publicConfigQuery.data?.registration_enabled, false) &&
configBool(publicConfigQuery.data?.password_register_enabled, false);
const passwordLoginEnabled = configBool(
publicConfigQuery.data?.password_login_enabled,
+15 -15
View File
@@ -12,6 +12,7 @@ import { Check } from 'lucide-react';
import { AuthService } from '@/lib/services/auth';
import { useAuth } from '@/components/providers/auth-provider';
import { safeRedirectTarget } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 登录页面组件
@@ -27,6 +28,7 @@ export function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, loading, setUser } = useAuth();
const t = useTranslations('auth.login');
const [showOTP, setShowOTP] = useState(false);
/* 处理OAuth回调 */
@@ -44,7 +46,7 @@ export function LoginPage() {
const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl');
const storedRedirect = sessionStorage.getItem('redirect_after_login');
const target = callbackUrl || storedRedirect || '/home';
const target = callbackUrl || storedRedirect || '/';
if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login');
@@ -87,9 +89,7 @@ export function LoginPage() {
try {
const result = await AuthService.handleCallback({ state, code });
if (result.status === 'need_bind') {
toast.info(
'您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。',
);
toast.info(t('needBind'));
setIsProcessingCallback(false);
router.replace('/login');
return;
@@ -98,7 +98,9 @@ export function LoginPage() {
setUser(result.user);
}
setLoginSuccess(true);
toast.success(result.status === 'bound' ? '绑定成功' : '登录成功');
toast.success(
result.status === 'bound' ? t('bindSuccess') : t('success'),
);
setTimeout(() => {
if (!redirectedRef.current) {
@@ -108,16 +110,14 @@ export function LoginPage() {
}, 1500);
} catch (error) {
console.error('OAuth callback error:', error);
toast.error(
error instanceof Error ? error.message : '登录失败,请重试',
);
toast.error(error instanceof Error ? error.message : t('failed'));
setIsProcessingCallback(false);
router.replace('/login');
}
}
};
handleOAuthCallback();
}, [router, searchParams, setUser]);
}, [router, searchParams, setUser, t]);
return (
<AuthShell wide={showOTP}>
@@ -138,10 +138,10 @@ export function LoginPage() {
</div>
<div className='flex flex-col gap-2 text-center'>
<h3 className='font-semibold tracking-tight text-foreground'>
正在检查登录状态
{t('checkingSession')}
</h3>
<p className='text-xs text-muted-foreground'>
请稍候,我们正在确认当前会话...
{t('checkingSessionDesc')}
</p>
</div>
</div>
@@ -157,10 +157,10 @@ export function LoginPage() {
</motion.div>
<div className='flex flex-col gap-2 text-center'>
<h3 className='font-semibold tracking-tight text-foreground'>
登录成功
{t('success')}
</h3>
<p className='text-xs text-muted-foreground'>
正在跳转至控制台...
{t('redirecting')}
</p>
</div>
</div>
@@ -171,10 +171,10 @@ export function LoginPage() {
</div>
<div className='flex flex-col gap-2 text-center'>
<h3 className='font-semibold tracking-tight text-foreground'>
正在验证凭据
{t('verifyingCredentials')}
</h3>
<p className='text-xs text-muted-foreground'>
请稍候,我们正在为您建立安全会话...
{t('establishingSession')}
</p>
</div>
</div>
+3 -3
View File
@@ -25,7 +25,7 @@ function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
typeof window === 'undefined'
? null
: sessionStorage.getItem('redirect_after_login');
return safeRedirectTarget(callbackUrl || storedRedirect || '/home');
return safeRedirectTarget(callbackUrl || storedRedirect || '/');
}
function configBool(value: string | undefined, fallback: boolean) {
@@ -68,8 +68,8 @@ export function RegisterForm() {
);
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true);
configBool(publicConfigQuery.data?.registration_enabled, false) &&
configBool(publicConfigQuery.data?.password_register_enabled, false);
const emailRegisterEnabled = configBool(
publicConfigQuery.data?.email_register_verification_enabled,
+412 -424
View File
@@ -11,61 +11,62 @@ import {
export const DOCS_LAST_UPDATED = '2026-06-07';
type TFn = (key: string) => string;
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: 'api-specs',
title: '1. 接口规范与鉴权说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
</p>
</div>
export function getApiSections(t: TFn): PolicySection[] {
return [
{
value: 'api-specs',
title: t('api.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{t('api.section1.desc')}
</p>
</div>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.1 统一响应格式
</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{t('api.section1.responseFormat')}
</h3>
<p>{t('api.section1.responseFormatDesc')}</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.field')}
</DocsTableHead>
<DocsTableHead className='w-[100px]'>
{t('api.type')}
</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section1.errorMsgDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
data
</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>{t('api.section1.dataDesc')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className='mt-2'>{t('api.section1.successExample')}</p>
<CodeBlock
code={`{
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
@@ -73,277 +74,263 @@ export function getApiSections(t: TFn): PolicySection[] {
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>{t('api.section1.failExample')}</p>
<CodeBlock
code={`{
"error_msg": "${t('api.section1.failExampleErrorMsg')}",
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language='json'
/>
language='json'
/>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{t('api.section1.authentication')}
</h3>
<p>{t('api.section1.authenticationDesc')}</p>
<ul className='list-disc pl-5 space-y-2'>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.2 鉴权方式
</h3>
<p>
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
Cookie 会话保持登录。
</li>
<li>
<strong>AccessToken 令牌:</strong>
供后台调用或第三方应用集成使用。客户端生成 API
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
支持携带令牌的请求头格式(二选一):
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<strong>{t('api.section1.sessionCredential')}</strong>
{t('api.section1.sessionCredentialDesc')}
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<strong>{t('api.section1.accessTokenCredential')}</strong>
{t('api.section1.accessTokenCredentialDesc')}
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
{t('api.section1.supportedHeaders')}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
</div>
</div>
),
children: [
{
value: '1-1-response-format',
title: t('api.section1.responseFormat'),
},
{
value: '1-2-authentication',
title: t('api.section1.authentication'),
},
],
},
{
value: 'auth-apis',
title: t('api.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section2.register')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.registerDesc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.param')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{t('api.required')}
</DocsTableHead>
<DocsTableHead>{t('api.type')}</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.usernameDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.passwordDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>{t('api.no')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.nicknameDesc')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
),
children: [
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: 'auth-apis',
title: '2. 用户与认证接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 用户注册
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.passwordLogin')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.passwordLoginDesc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.param')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{t('api.required')}
</DocsTableHead>
<DocsTableHead>{t('api.type')}</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.username')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.password')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.2 密码登录
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
Session Cookie 会话。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.logout')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.logoutDesc')}
</p>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.3 退出登录
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
</p>
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.getProfile')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.getProfileDesc')}
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.4 获取个人资料
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>说明:</strong>获取当前登录账户的基本数据模型(包含
ID、角色、昵称等)。
</p>
</div>
),
children: [
{ value: '2-1-register', title: '2.1 用户注册' },
{ value: '2-2-login', title: '2.2 密码登录' },
{ value: '2-3-logout', title: '2.3 退出登录' },
{ value: '2-4-profile', title: '2.4 获取个人资料' },
],
},
{
value: 'token-apis',
title: '3. 个人访问令牌 (AccessToken) 接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
AccessToken 管理相关接口均要求通过 Session
登录后调用,支持普通用户权限。
</p>
</div>
),
children: [
{ value: '2-1-register', title: t('api.section2.register') },
{ value: '2-2-login', title: t('api.section2.passwordLogin') },
{ value: '2-3-logout', title: t('api.section2.logout') },
{ value: '2-4-profile', title: t('api.section2.getProfile') },
],
},
{
value: 'token-apis',
title: t('api.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
{t('api.section3.desc')}
</p>
</div>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section3.listTokens')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.listTokensDesc')}
</p>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 获取令牌列表
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>说明:</strong>
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
</p>
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.createToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{t('api.param')}</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "${t('api.section3.tokenName')}", "is_admin": false}`}</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.createTokenDesc')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(
{t('api.optional')},{t('api.default')}{' '}
<code className='bg-muted px-1 rounded'>false</code>
):{t('api.section3.isAdminDesc')}{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code>{' '}
{t('api.section3.endpoint')}.
</p>
<p className='mt-2'>{t('api.section3.successResponseExample')}</p>
<CodeBlock
code={`{
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.2 新建访问令牌
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>参数:</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
</p>
<p>
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
Token,切勿遗失。
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
<code className='bg-muted px-1 rounded'>false</code>
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
</p>
<p className='mt-2'>成功返回样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -357,129 +344,130 @@ export function getApiSections(t: TFn): PolicySection[] {
}
}
}`}
language='json'
/>
language='json'
/>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.deleteToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.deleteTokenDesc')}
</p>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.3 撤销/删除令牌
</h3>
<p>
<strong>接口:</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>说明:</strong>通过 ID
物理删除对应访问令牌,该令牌将立即失效。
</p>
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.rotateToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.rotateTokenDesc')}
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: t('api.section3.listTokens') },
{ value: '3-2-create-token', title: t('api.section3.createToken') },
{ value: '3-3-delete-token', title: t('api.section3.deleteToken') },
{ value: '3-4-rotate-token', title: t('api.section3.rotateToken') },
],
},
{
value: 'config-apis',
title: t('api.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section4.publicConfig')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section4.publicConfigDesc')}
</p>
<p className='mt-2'>{t('api.section4.responseExample')}</p>
<CodeBlock
code={`{
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.4 轮换令牌密钥
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>说明:</strong>
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
Token,令牌名称与 ID 保持一致。
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: '3.1 获取令牌列表' },
{ value: '3-2-create-token', title: '3.2 新建访问令牌' },
{ value: '3-3-delete-token', title: '3.3 撤销/删除令牌' },
{ value: '3-4-rotate-token', title: '3.4 轮换令牌密钥' },
],
},
{
value: 'config-apis',
title: '4. 公共配置与管理接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
4.1 公共系统配置
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>说明:</strong>
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
</p>
<p className='mt-2'>返回数据结构样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "Wavelet",
"registration_enabled": "true",
"site_name": "OpenFlare",
"registration_enabled": "false",
"password_login_enabled": "true",
"password_register_enabled": "true",
"password_register_enabled": "false",
"cap_login_enabled": "false",
"oidc_login_enabled": "true"
}
}`}
language='json'
/>
language='json'
/>
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section4.adminConfigs')}
</h3>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section4.adminConfigsDesc')}
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{t('api.section4.getConfigList')}</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>{t('api.section4.createConfig')}</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>{t('api.section4.updateConfig')}</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>{t('api.section4.deleteConfig')}</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: t('api.section4.publicConfig') },
{ value: '4-2-admin-configs', title: t('api.section4.adminConfigs') },
],
},
];
}
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
4.2 系统配置项 CRUD (管理员)
</h3>
<p>
<strong>说明:</strong>用于在后台对 `system_configs`
配置进行动态变更,要求管理员权限会话调用。
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>获取配置列表:</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>新建配置项:</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>修改指定配置值:</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>删除配置项:</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
+245 -222
View File
@@ -1,231 +1,254 @@
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
type TFn = (key: string) => string;
export function getHowToUseSections(t: TFn): PolicySection[] {
return [
{
value: 'quick-start',
title: t('howToUse.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{t('howToUse.section1.desc')}
</p>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section1.architecture')}</strong>
{t('howToUse.section1.architectureDesc')}
</li>
<li>
<strong>{t('howToUse.section1.authSystem')}</strong>
{t('howToUse.section1.authSystemDesc')}
</li>
<li>
<strong>{t('howToUse.section1.accessToken')}</strong>
{t('howToUse.section1.accessTokenDesc')}
</li>
<li>
<strong>{t('howToUse.section1.observability')}</strong>
{t('howToUse.section1.observabilityDesc')}
</li>
</ul>
/**
* ------------------------------------------------------------------
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: 'quick-start',
title: '1. 快速开始',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
</p>
</div>
),
},
{
value: 'auth-security',
title: t('howToUse.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section2.desc')}</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section2.passwordAuth')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section2.selfRegister')}</strong>
{t('howToUse.section2.selfRegisterDesc')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
{t('howToUse.section2.bcryptDesc')}
</li>
<li>
<strong>{t('howToUse.section2.switchControl')}</strong>
{t('howToUse.section2.switchControlDesc')}
</li>
</ul>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 +
React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
</li>
<li>
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
第三方自定义 OIDC (OAuth2) 认证源绑定
</li>
<li>
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
Key),用于通过 Http Header 鉴权直接调用系统 API
</li>
<li>
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
全链路 Tracing 追踪
</li>
</ul>
</div>
),
},
{
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 常规账号密码认证
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>自主注册与密码登录:</strong>
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
高强度加盐哈希存储。
</li>
<li>
<strong>系统开关控制:</strong>
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section2.oidc')}
</h3>
<p>{t('howToUse.section2.oidcDesc')}</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>{t('howToUse.section2.oidcStep1')}</li>
<li>{t('howToUse.section2.oidcStep2')}</li>
<li>{t('howToUse.section2.oidcStep3')}</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: t('howToUse.section2.passwordAuth') },
{ value: '2-2-oidc', title: t('howToUse.section2.oidc') },
],
},
{
value: 'access-token',
title: t('howToUse.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section3.desc')}</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section3.generation')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section3.oneTimeDisplay')}</strong>
{t('howToUse.section3.oneTimeDisplayDesc')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
){t('howToUse.section3.oneTimeDisplayHint')}
</li>
<li>
<strong>{t('howToUse.section3.secureHash')}</strong>
{t('howToUse.section3.secureHashDesc')}
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.2 第三方 OIDC 认证源
</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
进入 <strong>设置 / 个人资料</strong> 页面。
</li>
<li>
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
认证源直接触发 OAuth2 绑定流。
</li>
<li>
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: 'access-token',
title: '3. 个人访问令牌 (AccessToken) 接口对接',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
为便于开发者或第三方工具直接调用系统
API,平台提供个人访问令牌管理功能。
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 令牌生成与存储规范
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
(形如{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
)
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
</li>
<li>
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section3.headerUsage')}
</h3>
<p>{t('howToUse.section3.headerUsageDesc')}</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{t('howToUse.section3.bearerHeader')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.2 携带 Header 进行 API 调用
</h3>
<p>
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
Http 请求头携带方式之一:
</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式一:Authorization Bearer 头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
Authorization: Bearer at_628d022b7a95e26b...`}
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{t('howToUse.section3.customHeader')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式二:X-Access-Token 自定义头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
</div>
language='http'
/>
</div>
),
children: [
{ value: '3-1-generation', title: t('howToUse.section3.generation') },
{ value: '3-2-usage', title: t('howToUse.section3.headerUsage') },
],
},
{
value: 'config-system',
title: t('howToUse.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section4.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section4.cacheAccel')}</strong>
{t('howToUse.section4.cacheAccelDesc')}
</li>
<li>
<strong>{t('howToUse.section4.coreConfigs')}</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:{t('howToUse.section4.siteName')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:{t('howToUse.section4.passwordLoginEnabled')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:{t('howToUse.section4.registrationEnabled')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:{t('howToUse.section4.maxApiKeys')}
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: t('howToUse.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section5.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section5.scheduler')}</strong>
{t('howToUse.section5.schedulerDesc')}
</li>
<li>
<strong>{t('howToUse.section5.worker')}</strong>
{t('howToUse.section5.workerDesc')}
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: t('howToUse.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section6.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section6.tracing')}</strong>
{t('howToUse.section6.tracingDesc')}
</li>
<li>
<strong>{t('howToUse.section6.structuredLog')}</strong>
{t('howToUse.section6.structuredLogDesc')}
</li>
</ul>
</div>
),
},
];
}
</div>
),
children: [
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: 'config-system',
title: '4. 动态系统配置项',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
</li>
<li>
<strong>核心系统配置项说明:</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:平台展示名称
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:密码登录开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:用户自主注册开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:普通用户创建令牌的最大数限制 (默认5)
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: '5. 异步任务与定时调度',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
服务解耦复杂计算或高延迟IO:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
表达式配置,定时将待执行任务推送到 Redis 队列中。
</li>
<li>
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>OpenTelemetry Tracing:</strong>自动传递 Tracing
上下文,所有经由 Gin 中间件、外部请求或 GORM
数据库的事务操作都将带有全局唯一的
Span,用于排查链路耗时或调用异常。
</li>
<li>
<strong>Zap 结构化日志:</strong>
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
等日志收集分析工具无缝对接。
</li>
</ul>
</div>
),
},
];
@@ -6,7 +6,6 @@ import { ArrowLeft, Calendar, Check, Copy, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion, useScroll, useSpring } from 'motion/react';
import { useTranslations } from 'next-intl';
import type { PolicySection } from './types';
interface LegalPageLayoutProps {
@@ -33,7 +32,6 @@ export function LegalPageLayout({
damping: 30,
restDelta: 0.001,
});
const t = useTranslations('docs');
const allSectionIds = React.useMemo(() => {
return sections.flatMap((section) => [
@@ -194,7 +192,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`${t('lastUpdated')}:${lastUpdated}`);
lines.push(`最后更新:${lastUpdated}`);
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -305,7 +303,7 @@ export function LegalPageLayout({
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className='w-4 h-4' />
{t('backToHome')}
返回首页
</Button>
</Link>
@@ -320,11 +318,11 @@ export function LegalPageLayout({
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8'>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<FileText className='w-3.5 h-3.5' />
{t('sectionCount', { count: sections.length })}
{sections.length} 个内容
</span>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<Calendar className='w-3.5 h-3.5' />
{t('lastUpdated')}:{lastUpdated}
最后更新:{lastUpdated}
</span>
<Button
type='button'
@@ -338,7 +336,7 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? t('copied') : t('copyMarkdown')}
{hasCopied ? '已复制' : '复制 Markdown'}
</Button>
</div>
{description && (
@@ -359,7 +357,7 @@ export function LegalPageLayout({
<div className='sticky top-24'>
<div className='space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin'>
<div className='text-sm font-semibold text-foreground mb-4 pl-3'>
{t('toc')}
目录
</div>
<nav className='space-y-1'>
{sections.map((section) => (
@@ -447,15 +445,15 @@ export function LegalPageLayout({
<div className='pt-16 border-t border-border mt-20'>
<div className='bg-muted/30 rounded-2xl p-8 text-center'>
<p className='text-muted-foreground text-sm mb-4'>
{t('haveQuestions')}
对这些内容有疑问?
</p>
<div className='flex justify-center gap-4'>
<Link
href='https://github.com/Rain-kl/Wavelet/issues'
href='https://github.com/Rain-kl/OpenFlare/issues'
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
{t('contactSupport')}
联系支持
</Button>
</Link>
</div>
+154 -137
View File
@@ -1,143 +1,160 @@
import { type PolicySection } from './types';
type TFn = (key: string) => string;
export function getPrivacySections(t: TFn): PolicySection[] {
return [
{
value: 'collection-details',
title: t('privacy.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section1.desc')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section1.authInfo')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section1.authInfoDesc')}
<strong>{t('privacy.section1.authInfoNote')}</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section1.logInfo')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section1.logInfoDesc')}
</p>
</div>
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: 'collection-details',
title: '1. 详细信息收集说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
1.1 身份鉴权信息:
</span>
<p className='mt-1 text-muted-foreground'>
当您通过本地账号注册或绑定第三方 OIDC
认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方
OpenID 标识符。
<strong>
我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
1.2 服务与接口日志信息:
</span>
<p className='mt-1 text-muted-foreground'>
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
IP 地址、访问日期和时间、个人访问令牌 API
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
</p>
</div>
</div>
),
},
{
value: 'storage-security',
title: t('privacy.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section2.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('privacy.section2.storageSecurity')}</strong>
{t('privacy.section2.storageSecurityDesc')}
</li>
<li>
<strong>{t('privacy.section2.encryption')}</strong>
{t('privacy.section2.encryptionDesc')}
</li>
<li>
<strong>{t('privacy.section2.accessControl')}</strong>
{t('privacy.section2.accessControlDesc')}
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: t('privacy.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section3.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>{t('privacy.section3.identity')}</strong>
{t('privacy.section3.identityDesc')}
</li>
<li>
<strong>{t('privacy.section3.business')}</strong>
{t('privacy.section3.businessDesc')}
</li>
<li>
<strong>{t('privacy.section3.riskControl')}</strong>
{t('privacy.section3.riskControlDesc')}
</li>
</ul>
<p>
<strong>{t('privacy.section3.prohibited')}</strong>
{t('privacy.section3.prohibitedDesc')}
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: t('privacy.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('privacy.section4.sharingPrinciple')}</strong>
{t('privacy.section4.sharingPrincipleDesc')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>{t('privacy.section4.sharingException1')}</li>
<li>{t('privacy.section4.sharingException2')}</li>
</ul>
<p>
<strong>{t('privacy.section4.transfer')}</strong>
{t('privacy.section4.transferDesc')}
</p>
</div>
),
},
{
value: 'user-rights',
title: t('privacy.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section5.desc')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section5.viewManage')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section5.viewManageDesc')}
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section5.deleteAccount')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section5.deleteAccountDesc')}
</p>
</div>
</div>
),
},
{
value: 'storage-security',
title: '2. 数据存储与安全保护',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>存储安全:</strong>
用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
</li>
<li>
<strong>加密技术:</strong>
敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API
数据传输链路强制使用 SSL/TLS 进行安全加密。
</li>
<li>
<strong>访问控制:</strong>我们实行严格的最小权限原则(Least
Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: '3. 信息使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们收集的信息将仅用于以下目的:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>身份识别:</strong>
用于确认您的注册身份,展示您的个人中心及设置页面数据。
</li>
<li>
<strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API
鉴权指令。
</li>
<li>
<strong>安全风控:</strong>利用 IP
及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
</li>
</ul>
<p>
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: '4. 信息共享与对外披露',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>4.1 共享原则:</strong>
除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>事先获得您的明确授权或同意;</li>
<li>
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
</li>
</ul>
<p>
<strong>4.2 转让与公开披露:</strong>
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
</p>
</div>
),
},
{
value: 'user-rights',
title: '5. 您的权利与数据管理',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
5.1 查阅与管理权:
</span>
<p className='mt-1 text-muted-foreground'>
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的
AccessToken (API 密钥)。
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
5.2 删除与注销权:
</span>
<p className='mt-1 text-muted-foreground'>
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
</p>
</div>
</div>
),
},
{
value: 'policy-update',
title: t('privacy.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section6.desc')}</p>
<p>{t('privacy.section6.notification')}</p>
</div>
),
},
];
}
</div>
),
},
{
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</div>
),
},
];
+151 -143
View File
@@ -2,150 +2,158 @@ import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = '2026-06-07';
type TFn = (key: string) => string;
export function getTermsSections(t: TFn): PolicySection[] {
return [
{
value: 'contract-establishment',
title: t('terms.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section1.subject')}</strong>
{t('terms.section1.subjectDesc')}
</p>
<p>
<strong>{t('terms.section1.readCarefully')}</strong>
{t('terms.section1.readCarefullyDesc')}
</p>
<p>
<strong>{t('terms.section1.composition')}</strong>
{t('terms.section1.compositionDesc')}
</p>
</div>
),
},
{
value: 'service-definition',
title: t('terms.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: 'contract-establishment',
title: '1. 缔约申明与服务综述',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>1.1 缔约主体:</strong>
本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
</p>
<p>
<strong>1.2 审慎阅读:</strong>
本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC
接入、API Key
令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
</p>
<p>
<strong>1.3 协议构成:</strong>
本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
</p>
</div>
),
},
{
value: 'service-definition',
title: '2. 服务定义与性质界定',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>责任自担:</strong>
关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
</li>
</ul>
</div>
),
},
{
value: 'account-specifications',
title: '3. 账号注册与使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>3.1 账号体系:</strong>
用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方
OIDC 认证源进行登录关联。
</p>
<p>
<strong>3.2 密码及令牌安全责任:</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>密码安全:</strong>您应妥善保管您账户的登录密码。
</li>
<li>
<strong>API Token 安全:</strong>个人生成的 AccessToken (API Key)
代表您账户的完整调用权限。
<strong>
因您保管不善导致 Token
泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: '4. 用户行为准则(负面清单)',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
<li>
<strong>{t('terms.section2.liability')}</strong>
{t('terms.section2.liabilityDesc')}
<strong>危害国家安全:</strong>
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
</li>
<li>
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
攻击服务、服务器爆破等其他非法信息服务平台;
</li>
<li>
<strong>黄赌毒关联:</strong>
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
</li>
<li>
<strong>侵犯知识产权:</strong>
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
</li>
<li>
<strong>其他违法信息:</strong>
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
</li>
</ul>
</div>
),
},
{
value: 'account-specifications',
title: t('terms.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section3.accountSystem')}</strong>
{t('terms.section3.accountSystemDesc')}
</p>
<p>
<strong>{t('terms.section3.securityResponsibility')}</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('terms.section3.passwordSecurity')}</strong>
{t('terms.section3.passwordSecurityDesc')}
</li>
<li>
<strong>{t('terms.section3.tokenSecurity')}</strong>
{t('terms.section3.tokenSecurityDesc')}
<strong>{t('terms.section3.tokenLeakLiability')}</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: t('terms.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{t('terms.section4.desc')}
<strong>{t('terms.section4.redLine')}</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
<li>
<strong>{t('terms.section4.nationalSecurity')}</strong>
{t('terms.section4.nationalSecurityDesc')}
</li>
<li>
<strong>{t('terms.section4.illegalInfo')}</strong>
{t('terms.section4.illegalInfoDesc')}
</li>
<li>
<strong>{t('terms.section4.harmfulContent')}</strong>
{t('terms.section4.harmfulContentDesc')}
</li>
<li>
<strong>{t('terms.section4.ipInfringement')}</strong>
{t('terms.section4.ipInfringementDesc')}
</li>
<li>
<strong>{t('terms.section4.otherIllegal')}</strong>
{t('terms.section4.otherIllegalDesc')}
</li>
</ul>
</div>
<p>
<strong>{t('terms.section4.enforcement')}</strong>
{t('terms.section4.enforcementDesc')}
<strong>{t('terms.section4.enforcementAction')}</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: t('terms.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section5.basicDisclaimer')}</strong>
{t('terms.section5.basicDisclaimerDesc')}
</p>
<p>
<strong>{t('terms.section5.techInterruption')}</strong>
{t('terms.section5.techInterruptionDesc')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>{t('terms.section5.naturalDisaster')}</li>
<li>{t('terms.section5.govAction')}</li>
<li>{t('terms.section5.telecomFailure')}</li>
<li>{t('terms.section5.hacking')}</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: t('terms.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section6.applicableLaw')}</strong>
{t('terms.section6.applicableLawDesc')}
<strong>{t('terms.section6.lawName')}</strong>
{t('terms.section6.lawScope')}
</p>
<p>
<strong>{t('terms.section6.disputeResolution')}</strong>
{t('terms.section6.disputeResolutionDesc')}
</p>
</div>
),
},
];
}
<p>
<strong>处理规则:</strong>
一旦发现您违反上述规定,平台运维主体有权不经通知
<strong>
立即永久封禁您的账号、拦截所有 AccessToken
请求,并依法向公安机关、网安部门移交相关线索。
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: '5. 免责声明与不可抗力',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>5.1 基础免责:</strong>
本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
</p>
<p>
<strong>5.2 技术服务中断:</strong>
对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: '6. 法律适用与争议解决',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>6.1 法律适用:</strong>
本协议的订立、执行、解释及争议的解决均适用
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
</p>
<p>
<strong>6.2 争议解决:</strong>
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
</p>
</div>
),
},
];
@@ -5,7 +5,6 @@ import { ImageOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getFileUrl, type ImageQuality } from '@/lib/services/upload/utils';
import { useTranslations } from 'next-intl';
type FileImagePreviewProps = {
fileId: string | number;
@@ -26,7 +25,6 @@ export function FileImagePreview({
}: FileImagePreviewProps) {
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
const t = useTranslations('common');
React.useEffect(() => {
setFailed(false);
@@ -36,7 +34,7 @@ export function FileImagePreview({
return (
<div
role='img'
aria-label={`${alt} ${t('loadFailed')}`}
aria-label={`${alt} 加载失败`}
className={cn(
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
fallbackClassName,
@@ -49,7 +47,7 @@ export function FileImagePreview({
)}
/>
{variant === 'default' && (
<span className='text-[10px] leading-none'>{t('loadFailed')}</span>
<span className='text-[10px] leading-none'>加载失败</span>
)}
</div>
);
@@ -24,7 +24,6 @@ import {
TableRow,
} from '@/components/ui/table';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useTranslations } from 'next-intl';
interface ManagePageProps<T> {
title: string;
@@ -79,12 +78,11 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription,
loadingDescription,
emptyDescription = '暂无数据',
loadingDescription = '加载中',
getId,
headerExtra,
}: ManagePageProps<T>) {
const t = useTranslations('general.manage');
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
@@ -127,10 +125,10 @@ export function ManagePage<T>({
try {
setSaving(true);
await onSave(selectedItem, editData);
toast.success(t('saveSuccess'));
toast.success('保存成功');
} catch (error) {
toast.error(t('saveFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setSaving(false);
@@ -146,7 +144,7 @@ export function ManagePage<T>({
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem);
toast.success(t('deleteSuccess'));
toast.success('删除成功');
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
@@ -154,8 +152,8 @@ export function ManagePage<T>({
setEditData({});
}
} catch (error) {
toast.error(t('deleteFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
});
}
};
@@ -166,7 +164,7 @@ export function ManagePage<T>({
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription ?? t('loading')}
description={loadingDescription}
/>
);
}
@@ -185,10 +183,7 @@ export function ManagePage<T>({
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription ?? t('noData')}
/>
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
);
}
@@ -221,7 +216,7 @@ export function ManagePage<T>({
<div className='space-y-6'>
<div>
<div className='mb-4'>
<div className='font-semibold'>{t('configList')}</div>
<div className='font-semibold'>配置列表</div>
</div>
{renderContent()}
</div>
@@ -244,18 +239,18 @@ export function ManagePage<T>({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmDeleteDesc')}
此操作无法撤销。这将永久删除该配置。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className='bg-red-600 hover:bg-red-700'
>
{t('confirmDeleteAction')}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -276,24 +271,18 @@ interface ManageDetailPanelProps {
/** 详情面板 */
export function ManageDetailPanel({
title,
title = '配置信息',
isEmpty,
emptyDescription,
emptyDescription = '请选择配置查看详情',
onSave,
saving = false,
children,
}: ManageDetailPanelProps) {
const t = useTranslations('general.manage');
const displayTitle = title ?? t('configInfo');
const displayEmptyDescription = emptyDescription ?? t('selectConfigToView');
if (isEmpty) {
return (
<div className='space-y-4'>
<div className='font-semibold mb-4'>{displayTitle}</div>
<EmptyStateWithBorder
icon={Layers}
description={displayEmptyDescription}
/>
<div className='font-semibold mb-4'>{title}</div>
<EmptyStateWithBorder icon={Layers} description={emptyDescription} />
</div>
);
}
@@ -302,7 +291,7 @@ export function ManageDetailPanel({
<div className='space-y-4 sticky top-6'>
<div>
<div className='flex items-center justify-between mb-4'>
<div className='font-semibold'>{displayTitle}</div>
<div className='font-semibold'>{title}</div>
{onSave && (
<Button
onClick={onSave}
@@ -312,10 +301,10 @@ export function ManageDetailPanel({
>
{saving ? (
<>
<Spinner /> {t('updating')}
<Spinner /> 更新中
</>
) : (
t('update')
'更新'
)}
</Button>
)}
@@ -352,7 +341,6 @@ export function ManageTable<T>({
onDelete?: (item: T) => void;
getId: (item: T) => string | number;
}) {
const t = useTranslations('general.manage');
return (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<ScrollArea className='w-full'>
@@ -370,7 +358,7 @@ export function ManageTable<T>({
))}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
{t('actions')}
操作
</TableHead>
)}
</TableRow>
@@ -16,7 +16,6 @@ import {
} from '@/components/ui/input-otp';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { useTranslations } from 'next-intl';
interface PasswordDialogProps {
isOpen: boolean;
@@ -37,13 +36,10 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title,
description,
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
}: PasswordDialogProps) {
const [password, setPassword] = useState('');
const t = useTranslations('general.passwordDialog');
const displayTitle = title ?? t('enterPassword');
const displayDescription = description ?? t('enterPasswordDesc');
const handleConfirm = () => {
if (password.length === 6) {
@@ -62,8 +58,8 @@ export function PasswordDialog({
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className='w-xs sm:w-md'>
<DialogHeader>
<DialogTitle>{displayTitle}</DialogTitle>
<DialogDescription>{displayDescription}</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className='py-2 flex justify-center'>
@@ -96,7 +92,7 @@ export function PasswordDialog({
disabled={loading}
className='h-8 text-xs'
>
{t('cancel')}
取消
</Button>
<Button
type='button'
@@ -107,10 +103,10 @@ export function PasswordDialog({
{loading ? (
<>
<Spinner className='mr-2 h-4 w-4' />
{t('verifying')}
验证中...
</>
) : (
t('confirm')
'确认'
)}
</Button>
</div>
+27 -38
View File
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
import {
ArrowRight,
ExternalLink,
FileText,
HelpCircle,
Layers,
Shield,
@@ -17,17 +16,15 @@ import {
User,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
export function HomeMain() {
const { user } = useUser();
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
const t = useTranslations('home');
const quickLinks = [
{
title: t('quickLinks.profile.title'),
description: t('quickLinks.profile.description'),
title: '个人资料',
description: '管理您的个人账户信息及个性化配置',
icon: User,
url: '/settings/profile',
color: 'text-blue-500',
@@ -35,20 +32,10 @@ export function HomeMain() {
borderColor: 'hover:border-blue-500/30',
},
{
title: t('quickLinks.apiDocs.title'),
description: t('quickLinks.apiDocs.description'),
icon: FileText,
url: '/docs/api',
color: 'text-emerald-500',
bgColor: 'bg-emerald-500/10',
borderColor: 'hover:border-emerald-500/30',
external: true,
},
{
title: t('quickLinks.userDocs.title'),
description: t('quickLinks.userDocs.description'),
title: '使用文档',
description: '学习如何集成 API 及日常操作帮助指南',
icon: HelpCircle,
url: '/docs/how-to-use',
url: 'https://openflare.fyrn.link/',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
borderColor: 'hover:border-purple-500/30',
@@ -58,8 +45,8 @@ export function HomeMain() {
const adminLinks = [
{
title: t('adminLinks.settings.title'),
description: t('adminLinks.settings.description'),
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
icon: Shield,
url: '/admin/settings',
color: 'text-amber-500',
@@ -67,8 +54,8 @@ export function HomeMain() {
borderColor: 'hover:border-amber-500/30',
},
{
title: t('adminLinks.systemConfig.title'),
description: t('adminLinks.systemConfig.description'),
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
icon: ShieldCheck,
url: '/admin/system',
color: 'text-indigo-500',
@@ -76,8 +63,8 @@ export function HomeMain() {
borderColor: 'hover:border-indigo-500/30',
},
{
title: t('adminLinks.userManagement.title'),
description: t('adminLinks.userManagement.description'),
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
icon: User,
url: '/admin/users',
color: 'text-rose-500',
@@ -85,8 +72,8 @@ export function HomeMain() {
borderColor: 'hover:border-rose-500/30',
},
{
title: t('adminLinks.tasks.title'),
description: t('adminLinks.tasks.description'),
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
icon: Layers,
url: '/admin/tasks',
color: 'text-teal-500',
@@ -94,8 +81,8 @@ export function HomeMain() {
borderColor: 'hover:border-teal-500/30',
},
{
title: t('adminLinks.logs.title'),
description: t('adminLinks.logs.description'),
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
icon: Terminal,
url: '/admin/logs',
color: 'text-cyan-500',
@@ -106,6 +93,7 @@ export function HomeMain() {
return (
<div className='py-6 space-y-8 max-w-6xl mx-auto'>
{/* 积分收益提示 banner */}
<AnimatePresence>
{isBannerVisible && (
<motion.div
@@ -121,7 +109,8 @@ export function HomeMain() {
Modern Platform
</h3>
<p className='text-xs text-muted-foreground leading-relaxed'>
{t('banner.description')}
通用的、现代化的后台管理系统脚手架,
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
</p>
<div className='flex gap-2.5'>
<Button
@@ -129,7 +118,7 @@ export function HomeMain() {
className='bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7'
asChild
>
<Link href='/home'>{t('banner.getStarted')}</Link>
<Link href='/'>开始使用</Link>
</Button>
<Button
size='sm'
@@ -137,7 +126,7 @@ export function HomeMain() {
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
onClick={() => setIsBannerVisible(false)}
>
{t('banner.hideHint')}
隐藏提示
</Button>
</div>
</div>
@@ -146,10 +135,9 @@ export function HomeMain() {
)}
</AnimatePresence>
{/* 快捷导航 */}
<div className='space-y-4'>
<h2 className='text-lg font-semibold tracking-tight'>
{t('navigation.title')}
</h2>
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{quickLinks.map((link, idx) => (
<motion.div
@@ -191,7 +179,7 @@ export function HomeMain() {
target='_blank'
rel='noopener noreferrer'
>
{t('jumpNow')} <ArrowRight className='size-3 ml-1' />
立即跳转 <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
) : (
@@ -201,7 +189,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
立即进入 <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -212,11 +200,12 @@ export function HomeMain() {
</div>
</div>
{/* 管理面板 (仅管理员可见) */}
{user?.is_admin && (
<div className='space-y-4 pt-2'>
<h2 className='text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5'>
<Shield className='size-5' />
{t('adminConsole')}
后台管理控制台
</h2>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{adminLinks.map((link, idx) => (
@@ -251,7 +240,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
立即进入 <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -0,0 +1,160 @@
'use client';
import { useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { html } from '@codemirror/lang-html';
import { Expand, Terminal } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
previewOriginErrorPageHTML,
} from '@/lib/openflare/default-origin-error-page-html';
type HtmlEditorWorkspaceProps = {
value: string;
onChange: (value: string) => void;
toolbarRight?: React.ReactNode;
maxBytes?: number;
preview?: (html: string) => string;
footerHint?: React.ReactNode;
showPreviewLink?: boolean;
previewTitle?: string;
};
/**
* 通用 HTML 编辑器工作区:左代码 / 右实时预览横向布局,可拖拽分隔。
*/
export function HtmlEditorWorkspace({
value,
onChange,
toolbarRight,
maxBytes = ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
preview = previewOriginErrorPageHTML,
footerHint = (
<>
{'{{status}}'}→502 · {'{{host}}'}→example.com
</>
),
showPreviewLink = true,
previewTitle = '源站错误页实时预览',
}: HtmlEditorWorkspaceProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
const { resolvedTheme } = useTheme();
const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
const previewSrcDoc = preview(value);
const htmlBytes = new TextEncoder().encode(value).length;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const handleMouseMove = (moveEvent: MouseEvent) => {
const x = moveEvent.clientX - rect.left;
const pct = (x / rect.width) * 100;
if (pct > 25 && pct < 75) {
setEditorWidthPercent(pct);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
ref={containerRef}
className='w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-160px)] min-h-[520px]'
>
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2 min-w-0'>
<Terminal className='size-4 text-primary shrink-0' />
<span className='text-xs font-semibold'>HTML 编辑器</span>
<span className='text-[11px] text-muted-foreground font-mono truncate'>
{htmlBytes} / {maxBytes} 字节
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
</span>
</div>
<div className='flex items-center gap-1.5 flex-wrap'>
{toolbarRight}
</div>
</div>
<div className='flex flex-1 min-h-0 overflow-hidden'>
<div
style={{ width: `${editorWidthPercent}%` }}
className='h-full overflow-hidden relative min-w-[200px] bg-background border-r border-border/40'
>
<CodeMirror
value={value}
height='100%'
extensions={[html()]}
theme={cmTheme}
onChange={onChange}
className='h-full text-xs font-mono'
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: false,
indentOnInput: true,
}}
placeholder='留空使用内置默认模板…'
/>
</div>
<div
onMouseDown={handleMouseDown}
className='w-1.5 bg-border/60 hover:bg-primary/50 cursor-col-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整宽度'
>
<div className='h-8 w-1 rounded bg-muted-foreground/30' />
</div>
<div className='flex-1 min-w-[200px] flex flex-col overflow-hidden bg-muted/10'>
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
<span className='font-semibold'>实时预览</span>
<div className='flex items-center gap-2'>
<span
className={footerHint === null ? 'hidden' : 'hidden sm:inline'}
>
{footerHint ?? null}
</span>
{showPreviewLink ? (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-[11px]'
asChild
>
<Link href='/error-pages/preview'>
<Expand className='size-3' />
预览
</Link>
</Button>
) : null}
</div>
</div>
<div className='flex-1 min-h-0 overflow-hidden bg-background'>
<iframe
title={previewTitle}
sandbox=''
srcDoc={previewSrcDoc}
className='h-full w-full border-0 bg-background'
/>
</div>
</div>
</div>
</div>
);
}
@@ -284,22 +284,6 @@ export function AccessTokenMain() {
</div>
</div>
<div className='flex items-center gap-2 shrink-0 sm:self-center'>
<Button
type='button'
variant='outline'
size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5'
onClick={() =>
handleCopyText(token.masked_token, token.id)
}
>
{copiedId === token.id ? (
<Check className='size-3.5 mr-1 text-emerald-500' />
) : (
<Copy className='size-3.5 mr-1' />
)}
{ta('copy')}
</Button>
<Button
type='button'
variant='outline'
@@ -5,10 +5,11 @@ import Link from 'next/link';
import { Check, Loader2, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { useCustomTheme } from '@/lib/theme';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '@/components/common/language-switcher';
import { useTranslations } from 'next-intl';
import {
Breadcrumb,
BreadcrumbItem,
@@ -17,7 +18,6 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { LanguageSwitcher } from '@/components/common/language-switcher';
function ThemeModeSection() {
const { theme, setTheme } = useTheme();
@@ -180,6 +180,7 @@ export function AuthSourceModal({
</div>
</div>
<Switch
aria-label={t('enableAuthSource')}
checked={form.is_active}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, is_active: checked }))
@@ -1,212 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bot, Loader2, Unlink } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { UserMessageGatewayService } from '@/lib/services/message-gateway';
export function BotBindingCard() {
const t = useTranslations('settings.botBinding');
const queryClient = useQueryClient();
const [open, setOpen] = React.useState(false);
const [channelId, setChannelId] = React.useState('');
const [code, setCode] = React.useState('');
const bindingsQuery = useQuery({
queryKey: ['message-gateway', 'bindings'],
queryFn: () => UserMessageGatewayService.listBindings(),
});
const channelsQuery = useQuery({
queryKey: ['message-gateway', 'channels'],
queryFn: () => UserMessageGatewayService.listChannels(),
enabled: open,
});
const bindMutation = useMutation({
mutationFn: () =>
UserMessageGatewayService.bind({ channel_id: channelId, code }),
onSuccess: () => {
toast.success(t('bindSuccess'));
queryClient.invalidateQueries({
queryKey: ['message-gateway', 'bindings'],
});
setOpen(false);
setChannelId('');
setCode('');
},
onError: (err: unknown) => {
toast.error(t('bindFailed') + ': ' + (err as Error).message);
},
});
const unbindMutation = useMutation({
mutationFn: (id: string) => UserMessageGatewayService.unbind(id),
onSuccess: () => {
toast.success(t('unbindSuccess'));
queryClient.invalidateQueries({
queryKey: ['message-gateway', 'bindings'],
});
},
onError: (err: unknown) => {
toast.error(t('unbindFailed') + ': ' + (err as Error).message);
},
});
const typeLabel = (typ: string) =>
typ === 'qq' ? t('typeQQ') : t('typeTelegram');
return (
<div className='space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col'>
<div className='border-b pb-4 flex items-center gap-2'>
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
<Bot className='size-4' />
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>
{t('title')}
</h2>
<p className='text-[11px] text-muted-foreground'>
{t('description')}
</p>
</div>
</div>
<div className='space-y-2'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
{t('bound')}
</h3>
{bindingsQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
<Loader2 className='size-4 animate-spin text-primary' />
</div>
) : bindingsQuery.isError ? (
<p className='text-[11px] text-destructive'>{t('loadFailed')}</p>
) : (bindingsQuery.data ?? []).length > 0 ? (
<div className='space-y-2'>
{(bindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card'
>
<div className='space-y-0.5 min-w-0'>
<span className='font-semibold text-xs block truncate'>
{binding.channel_name || typeLabel(binding.channel_type)}
</span>
<span className='text-[10px] text-muted-foreground font-mono block truncate'>
{t('platformUserId')}: {binding.platform_user_id}
</span>
</div>
<Button
type='button'
variant='ghost'
size='sm'
className='text-[11px] text-muted-foreground hover:text-rose-500 h-7 px-2'
disabled={unbindMutation.isPending}
onClick={() => unbindMutation.mutate(binding.id)}
>
<Unlink className='size-3 mr-1' />
{t('unbind')}
</Button>
</div>
))}
</div>
) : (
<div className='rounded-xl border border-dashed px-4 py-6 text-center text-[11px] text-muted-foreground'>
{t('empty')}
</div>
)}
</div>
<Button
type='button'
variant='outline'
className='w-full border-dashed'
onClick={() => setOpen(true)}
>
{t('bind')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('bindDialogTitle')}</DialogTitle>
<DialogDescription>{t('bindDialogDescription')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('selectChannel')}</Label>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger>
<SelectValue placeholder={t('selectChannel')} />
</SelectTrigger>
<SelectContent>
{(channelsQuery.data ?? []).map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
{ch.name} ({typeLabel(ch.type)})
</SelectItem>
))}
</SelectContent>
</Select>
{!channelsQuery.isPending &&
(channelsQuery.data ?? []).length === 0 ? (
<p className='text-xs text-muted-foreground'>
{t('noChannels')}
</p>
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='mg-code'>{t('pairingCode')}</Label>
<Input
id='mg-code'
value={code}
placeholder={t('pairingCodePlaceholder')}
autoCapitalize='characters'
onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button
disabled={
!channelId || code.trim() === '' || bindMutation.isPending
}
onClick={() => bindMutation.mutate()}
>
{bindMutation.isPending ? (
<Loader2 className='size-4 animate-spin' />
) : null}
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,13 +1,13 @@
'use client';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { KeyRound, ShieldAlert, X } from 'lucide-react';
import { Database, KeyRound, Save, ShieldAlert, X } from 'lucide-react';
import {
Card,
CardContent,
@@ -16,6 +16,10 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -29,6 +33,24 @@ import { TemplatesManager } from './templates';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
const LOG_RETENTION_FIELDS = [
{
key: 'log_retention_days_postgres',
label: 'PostgreSQL',
descKey: 'retentionPostgresDesc',
},
{
key: 'log_retention_days_sqlite',
label: 'SQLite',
descKey: 'retentionSqliteDesc',
},
{
key: 'log_retention_days_clickhouse',
label: 'ClickHouse',
descKey: 'retentionClickhouseDesc',
},
] as const;
interface OperationTabProps {
configs: Record<string, SystemConfig>;
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
@@ -46,6 +68,65 @@ export function OperationTab({
queryFn: () => services.adminSystemConfig.listUploadTypes(),
});
const businessConfigsQuery = useQuery({
queryKey: ['admin', 'system-configs', 'business'],
queryFn: () => services.adminSystemConfig.listSystemConfigs('business'),
});
const businessConfigs = useMemo(() => {
return (businessConfigsQuery.data ?? []).reduce<
Record<string, SystemConfig>
>((acc, config) => {
acc[config.key] = config;
return acc;
}, {});
}, [businessConfigsQuery.data]);
const [retentionValues, setRetentionValues] = useState<
Record<string, string>
>({});
useEffect(() => {
if (!businessConfigsQuery.data) return;
setRetentionValues((prev) => {
const next: Record<string, string> = {};
LOG_RETENTION_FIELDS.forEach((field) => {
const config = businessConfigs[field.key];
next[field.key] = config?.value || prev[field.key] || '90';
});
return next;
});
}, [businessConfigsQuery.data, businessConfigs]);
const updateRetentionMutation = useMutation({
mutationFn: async (values: Record<string, string>) => {
for (const field of LOG_RETENTION_FIELDS) {
const raw = (values[field.key] ?? '').trim();
const num = Number(raw);
if (!raw || !Number.isInteger(num) || num < 1) {
throw new Error(t('retentionInvalid', { label: field.label }));
}
const config = businessConfigs[field.key];
if (!config) {
throw new Error(`缺少配置项: ${field.key}`);
}
await services.adminSystemConfig.updateSystemConfig(field.key, {
value: String(num),
description: config.description,
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success(t('logRetentionUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || t('logRetentionUpdateFailed'));
},
});
const updateWhitelistMutation = useMutation({
mutationFn: async (newValue: string) => {
const config = configs['file_access_whitelist'];
@@ -101,15 +182,15 @@ export function OperationTab({
const availableTypes = useMemo(() => {
const types = uploadTypesQuery.data ?? [];
return types.map((t) => {
let label = t;
if (t === 'avatar') label = '头像 (avatar)';
else if (t === 'attachment') label = '附件 (attachment)';
else if (t === 'doc') label = '文档 (doc)';
else if (t === 'generic') label = '通用 (generic)';
return { value: t, label };
return types.map((type) => {
let label = type;
if (type === 'avatar') label = t('typeAvatar');
else if (type === 'attachment') label = t('typeAttachment');
else if (type === 'doc') label = t('typeDoc');
else if (type === 'generic') label = t('typeGeneric');
return { value: type, label };
});
}, [uploadTypesQuery.data]);
}, [t, uploadTypesQuery.data]);
return (
<div className='space-y-6'>
@@ -210,6 +291,77 @@ export function OperationTab({
</CardContent>
</Card>
{/* 日志保留时间设置 */}
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<div className='flex items-center gap-2'>
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
<Database className='size-4' />
</div>
<div>
<CardTitle className='text-base font-semibold'>
{t('logRetentionTitle')}
</CardTitle>
<CardDescription className='text-xs'>
{t('logRetentionDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<div className='grid gap-4 sm:grid-cols-3'>
{LOG_RETENTION_FIELDS.map((field) => (
<div key={field.key} className='grid gap-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<div className='flex items-center gap-2'>
<Input
id={field.key}
type='number'
min={1}
className='text-xs'
value={retentionValues[field.key] ?? ''}
disabled={
updateRetentionMutation.isPending ||
businessConfigsQuery.isPending
}
onChange={(e) =>
setRetentionValues((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
/>
<span className='text-xs text-muted-foreground whitespace-nowrap'>
{t('days')}
</span>
</div>
<p className='text-[10px] text-muted-foreground'>
{t(field.descKey)}
</p>
</div>
))}
</div>
<div className='mt-4 flex justify-end'>
<Button
type='button'
size='sm'
onClick={() => updateRetentionMutation.mutate(retentionValues)}
disabled={
updateRetentionMutation.isPending ||
businessConfigsQuery.isPending
}
>
{updateRetentionMutation.isPending ? (
<Spinner className='size-3' />
) : (
<Save className='size-3' />
)}
{t('save')}
</Button>
</div>
</CardContent>
</Card>
{/* 通知模板管理 */}
<TemplatesManager />
</div>
+112 -50
View File
@@ -4,8 +4,6 @@ import { ComponentType, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Bell,
Code,
CreditCard,
Database,
FileText,
FolderOpen,
@@ -17,6 +15,13 @@ import {
ShieldCheck,
Terminal,
UserRound,
LayoutDashboard,
Route,
Server,
Globe,
GitBranch,
ScrollText,
Gauge,
} from 'lucide-react';
import {
@@ -34,108 +39,163 @@ import { useTranslations } from 'next-intl';
interface MenuItem {
path: string;
label: string;
description: string;
labelKey: string;
descKey: string;
icon: ComponentType<{ className?: string }>;
readOnly?: boolean;
}
interface MenuGroup {
name: string;
nameKey: string;
items: MenuItem[];
}
const MENU_GROUPS: MenuGroup[] = [
{
name: '基础菜单',
nameKey: 'groupBusiness',
items: [
{
path: '/home',
label: '首页',
description: '系统控制台/个人首页',
path: '/',
labelKey: 'dashboard',
descKey: 'descDashboard',
icon: LayoutDashboard,
readOnly: true,
},
{
path: '/nodes',
labelKey: 'nodes',
descKey: 'descNodes',
icon: Server,
},
{
path: '/proxy-routes',
labelKey: 'proxyRoutes',
descKey: 'descProxyRoutes',
icon: Route,
},
{
path: '/websites',
labelKey: 'websites',
descKey: 'descWebsites',
icon: Globe,
},
{
path: '/certificates',
labelKey: 'certificates',
descKey: 'descCertificates',
icon: ShieldCheck,
},
{
path: '/dns-accounts',
labelKey: 'dnsAccounts',
descKey: 'descDnsAccounts',
icon: Settings,
},
{
path: '/origins',
labelKey: 'origins',
descKey: 'descOrigins',
icon: Home,
},
{
path: '/files',
label: '我的文件',
description: '用户个人文件管理与上传',
icon: FolderOpen,
path: '/waf',
labelKey: 'waf',
descKey: 'descWaf',
icon: ShieldCheck,
},
{
path: '/ip-groups',
labelKey: 'ipGroups',
descKey: 'descIpGroups',
icon: Layers,
},
{
path: '/pages',
labelKey: 'pages',
descKey: 'descPages',
icon: FileText,
},
{
path: '/config-versions',
labelKey: 'configVersions',
descKey: 'descConfigVersions',
icon: GitBranch,
},
{
path: '/access-logs',
labelKey: 'accessLogs',
descKey: 'descAccessLogs',
icon: ScrollText,
},
{
path: '/performance',
labelKey: 'performance',
descKey: 'descPerformance',
icon: Gauge,
},
],
},
{
name: '管理菜单',
nameKey: 'groupAdmin',
items: [
{
path: '/admin/users',
label: '用户管理',
description: '查看和管理系统用户列表及其状态',
labelKey: 'users',
descKey: 'descUsers',
icon: UserRound,
},
{
path: '/admin/tasks',
label: '任务管理',
description: '查看和调度系统异步及定时任务',
labelKey: 'tasks',
descKey: 'descTasks',
icon: Layers,
},
{
path: '/admin/files',
label: '存储管理',
description: '管理系统文件存储和清理无用文件',
labelKey: 'storage',
descKey: 'descStorage',
icon: FolderOpen,
},
{
path: '/admin/database',
label: '数据管理',
description: '监控物理数据库状态、分页浏览表数据及交互式 SQL 查询',
labelKey: 'database',
descKey: 'descDatabase',
icon: Database,
},
{
path: '/admin/push',
label: '通知推送',
description: '配置和发送系统通知及推送消息',
labelKey: 'push',
descKey: 'descPush',
icon: Bell,
},
{
path: '/admin/logs',
label: '系统日志',
description: '查看异步任务执行日志和系统运行情况',
labelKey: 'logs',
descKey: 'descLogs',
icon: Terminal,
},
{
path: '/admin/system',
label: '系统配置',
description: '管理和维护系统基础键值对配置',
labelKey: 'system',
descKey: 'descSystem',
icon: ShieldCheck,
},
{
path: '/admin/settings',
label: '系统设置',
description: '配置安全验证、邮箱服务及目录显示',
labelKey: 'adminSettings',
descKey: 'descAdminSettings',
icon: Settings,
readOnly: true,
},
],
},
{
name: '文档菜单',
nameKey: 'groupDocs',
items: [
{
path: '/admin/demo',
label: '规范示例',
description: '内置 UI 组件与设计规范的展示、调试与参考',
icon: Code,
},
{
path: '/docs/api',
label: '接口文档',
description: '系统 Swagger 交互式 API 接口文档',
icon: CreditCard,
},
{
path: '/docs/how-to-use',
label: '使用文档',
description: '面向开发与运营的部署使用指南',
path: 'https://openflare.fyrn.link/',
labelKey: 'usageDocs',
descKey: 'descUsageDocs',
icon: FileText,
},
],
@@ -149,6 +209,7 @@ interface OtherTabProps {
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.other');
const tNav = useTranslations('layout.nav');
const menuDisplayConfig = useMemo(() => {
const raw = configs['menu_display_config']?.value;
@@ -215,10 +276,10 @@ export function OtherTab({ configs }: OtherTabProps) {
</CardHeader>
<CardContent className='pt-6 space-y-6'>
{MENU_GROUPS.map((group) => (
<div key={group.name} className='space-y-3'>
<div key={group.nameKey} className='space-y-3'>
<div className='flex items-center gap-2'>
<span className='text-xs font-semibold text-muted-foreground tracking-wider uppercase'>
{group.name}
{t(group.nameKey)}
</span>
<div className='h-px bg-border/40 flex-1' />
</div>
@@ -239,7 +300,7 @@ export function OtherTab({ configs }: OtherTabProps) {
<Icon className='size-4 text-primary shrink-0' />
)}
<span className='font-medium text-sm text-foreground truncate'>
{item.label}
{tNav(item.labelKey)}
</span>
{isReadOnly && (
<span className='text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0'>
@@ -248,11 +309,12 @@ export function OtherTab({ configs }: OtherTabProps) {
)}
</div>
<p className='text-xs text-muted-foreground leading-normal line-clamp-2'>
{item.description}
{t(item.descKey)}
</p>
</div>
<div className='flex items-center'>
<Switch
aria-label={tNav(item.labelKey)}
checked={checked}
disabled={
isReadOnly || updateMenuConfigMutation.isPending
@@ -65,7 +65,6 @@ import {
} from '@/components/ui/image-crop';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { BotBindingCard } from '@/components/common/settings/bot-binding-card';
export function ProfileMain() {
const { user, loading, refetch } = useUser();
@@ -652,7 +651,6 @@ export function ProfileMain() {
</div>
</div>
</div>
<BotBindingCard />
</div>
{/* 编辑资料 Dialog */}
@@ -175,7 +175,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
},
});
const handleSessionTTLChange = (val: string) => {
const handleTTLChange = (val: string) => {
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
@@ -291,7 +291,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
},
});
const handleCapSubmit = (e: React.FormEvent) => {
const handleCapSave = (e: React.FormEvent) => {
e.preventDefault();
saveCapMutation.mutate();
};
@@ -329,15 +329,19 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<div className='space-y-1'>
<div className='flex items-center gap-2'>
{Icon && <Icon className='size-4 text-primary' />}
<span className='font-medium text-sm text-foreground'>
<Label
htmlFor={item.key}
className='font-medium text-sm text-foreground'
>
{t(item.titleKey)}
</span>
</Label>
</div>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
{t(item.descKey)}
</p>
</div>
<Switch
id={item.key}
checked={checked}
disabled={updateConfigMutation.isPending}
onCheckedChange={(value) => handleToggle(item.key, value)}
@@ -364,9 +368,12 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<Select
value={sessionTTL}
disabled={updateTTLMutation.isPending}
onValueChange={handleSessionTTLChange}
onValueChange={handleTTLChange}
>
<SelectTrigger className='w-[180px] bg-card border-dashed text-xs h-8'>
<SelectTrigger
className='w-[180px] bg-card border-dashed text-xs h-8'
aria-label={t('loginSessionTTL')}
>
<SelectValue placeholder={t('selectRetentionTime')} />
</SelectTrigger>
<SelectContent>
@@ -554,17 +561,24 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
onCheckedChange={(checked) =>
handleToggle('cap_login_enabled', checked)
}
aria-label={t('loginCaptchaEnabled')}
/>
</CardHeader>
<CardContent className='pt-6'>
{/* 自动开始计算 Switch */}
<div className='flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4'>
<div className='space-y-0.5'>
<p className='text-sm font-semibold'>{t('autoStartSolving')}</p>
<Label htmlFor='cap_auto_solve' className='text-sm font-semibold'>
{t('autoStartSolving')}
</Label>
</div>
<Switch checked={capAutoSolve} onCheckedChange={setCapAutoSolve} />
<Switch
id='cap_auto_solve'
checked={capAutoSolve}
onCheckedChange={setCapAutoSolve}
/>
</div>
<form onSubmit={handleCapSubmit} className='space-y-6'>
<form onSubmit={handleCapSave} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label
@@ -123,7 +123,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
},
});
const handleSystemSubmit = (e: React.FormEvent) => {
const handleSystemSave = (e: React.FormEvent) => {
e.preventDefault();
saveSystemMutation.mutate();
};
@@ -165,7 +165,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
},
});
const handleSmtpSubmit = (e: React.FormEvent) => {
const handleSmtpSave = (e: React.FormEvent) => {
e.preventDefault();
saveSmtpMutation.mutate();
};
@@ -238,7 +238,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSystemSubmit} className='flex flex-col gap-6'>
<form onSubmit={handleSystemSave} className='flex flex-col gap-6'>
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<div className='flex flex-col gap-1.5'>
<Label htmlFor='site_name' className='text-xs font-semibold'>
@@ -249,7 +249,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='text'
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder='例如: Wavelet'
placeholder='例如: OpenFlare'
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
@@ -372,7 +372,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSmtpSubmit} className='space-y-6'>
<form onSubmit={handleSmtpSave} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='smtp_host' className='text-xs font-semibold'>
@@ -340,7 +340,7 @@ export function TemplatesManager() {
type='text'
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder='例如: Wavelet 登录验证码'
placeholder='例如: OpenFlare 登录验证码'
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
+51
View File
@@ -0,0 +1,51 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { RankChart } from './rank-chart';
type RankCardItem = {
label: string;
value: number;
};
type RankCardProps = {
title: string;
description: string;
items: RankCardItem[];
color?: string;
valueFormatter?: (value: number) => string;
};
export function RankCard({
title,
description,
items,
color = '#3b82f6',
valueFormatter,
}: RankCardProps) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
<RankChart
items={items}
color={color}
valueFormatter={valueFormatter}
emptyMessage={`暂无 ${title} 数据`}
/>
</CardContent>
</Card>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
type RankChartItem = {
label: string;
value: number;
};
type RankChartProps = {
items: RankChartItem[];
color: string;
valueFormatter?: (value: number) => string;
emptyMessage?: string;
className?: string;
};
const defaultFormatter = (value: number) => {
if (value >= 1000000) {
return `${Number((value / 1000000).toFixed(2))}M`;
}
if (value >= 1000) {
return `${Number((value / 1000).toFixed(2))}k`;
}
return value.toLocaleString('zh-CN');
};
export function RankChart({
items,
color,
valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据',
className,
}: RankChartProps) {
const maxValue = useMemo(
() => Math.max(0, ...items.map((item) => item.value)),
[items],
);
if (items.length === 0) {
return (
<div
className={cn(
'flex h-[320px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground',
className,
)}
>
{emptyMessage}
</div>
);
}
return (
<div
className={cn(
'h-[320px] space-y-0.5 overflow-y-auto pr-1 hide-scrollbar',
className,
)}
>
{items.map((item) => {
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<div
key={item.label}
className='group flex items-center justify-between gap-3 py-1.5 text-sm'
>
<span
className='min-w-0 flex-1 truncate text-[13px] font-normal text-foreground/90'
title={item.label}
>
{item.label}
</span>
<div className='flex items-center gap-3 shrink-0'>
<div className='h-1.5 w-24 sm:w-28 overflow-hidden rounded-full bg-muted/60'>
<div
className='h-full rounded-full transition-[width] duration-300'
style={{
width: `${ratio}%`,
backgroundColor: color,
}}
/>
</div>
<span className='min-w-14 max-w-28 text-right tabular-nums text-foreground/80 text-[13px] font-medium'>
{valueFormatter(item.value)}
</span>
</div>
</div>
);
})}
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
type TrendChartSeries = {
label: string;
color: string;
fillColor?: string;
values: number[];
variant?: 'line' | 'area';
valueFormatter?: (value: number) => string;
};
type TrendChartSummaryScope = 'last-point' | 'total' | 'average';
type TrendChartProps = {
labels: string[];
series: TrendChartSeries[];
height?: number;
yAxisValueFormatter?: (value: number) => string;
showSummary?: boolean;
summaryScope?: TrendChartSummaryScope;
summaryHint?: string;
};
type TooltipParam = {
axisValueLabel?: string;
color?: string;
seriesName?: string;
value?: number | string | Array<number | string>;
};
const defaultFormatter = (value: number) => formatCompactNumber(value);
function resolveSummaryValue(values: number[], scope: TrendChartSummaryScope) {
if (values.length === 0) {
return 0;
}
if (scope === 'total') {
return values.reduce((sum, value) => sum + value, 0);
}
if (scope === 'average') {
const sum = values.reduce((acc, value) => acc + value, 0);
return sum / values.length;
}
return values[values.length - 1] ?? 0;
}
export function TrendChart({
labels,
series,
height = 220,
yAxisValueFormatter,
showSummary = true,
summaryScope = 'last-point',
summaryHint,
}: TrendChartProps) {
const option = useMemo<EChartsOption>(() => {
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
const maxValue = calculateNiceAxisMax(
series.flatMap((item) => item.values),
);
return {
animationDuration: 500,
animationEasing: 'cubicOut',
grid: {
left: 16,
right: 16,
top: 20,
bottom: 20,
containLabel: true,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
if (items.length === 0) {
return '';
}
const header = items[0]?.axisValueLabel ?? '';
const rows = items.map((item) => {
const matchedSeries = series.find(
(seriesItem) => seriesItem.label === item.seriesName,
);
const formatter =
matchedSeries?.valueFormatter ??
yAxisValueFormatter ??
defaultFormatter;
const rawValue = Array.isArray(item.value)
? item.value[1]
: item.value;
const numericValue =
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
return [
'<span style="display:inline-flex;align-items:center;gap:8px;">',
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
`<span>${item.seriesName ?? ''}</span>`,
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
'</span>',
].join('');
});
return [header, ...rows].join('<br/>');
},
},
legend: {
show: false,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.24)',
},
},
axisTick: {
show: false,
},
axisLabel: {
color: '#94a3b8',
margin: 14,
},
},
yAxis: {
type: 'value',
min: 0,
max: maxValue,
splitNumber: 4,
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => axisFormatter(value),
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
series: series.map((item) => ({
name: item.label,
type: 'line',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: item.color,
width: 3,
},
itemStyle: {
color: item.color,
},
areaStyle:
item.variant === 'area'
? {
color: item.fillColor ?? `${item.color}33`,
}
: undefined,
emphasis: {
focus: 'series',
scale: true,
},
data: item.values,
})),
};
}, [labels, series, yAxisValueFormatter]);
if (labels.length === 0 || series.length === 0) {
return (
<div className='flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground'>
暂无趋势数据
</div>
);
}
return (
<div className='space-y-4'>
{showSummary ? (
<div className='flex flex-wrap gap-3'>
{series.map((item) => {
const summaryValue = resolveSummaryValue(item.values, summaryScope);
const formatter = item.valueFormatter ?? defaultFormatter;
return (
<div
key={item.label}
className='min-w-[140px] rounded-2xl border bg-card px-4 py-3'
>
<div className='flex items-center gap-2'>
<span
className='h-2.5 w-2.5 rounded-full'
style={{ backgroundColor: item.color }}
/>
<p className='text-xs tracking-[0.18em] text-muted-foreground uppercase'>
{item.label}
</p>
</div>
<p className='mt-2 text-lg font-semibold'>
{formatter(summaryValue)}
</p>
{summaryHint ? (
<p className='mt-1 text-[10px] text-muted-foreground'>
{summaryHint}
</p>
) : null}
</div>
);
})}
</div>
) : null}
<div className='overflow-hidden rounded-[28px] border bg-linear-to-b from-white/3 to-transparent px-4 py-4 dark:from-white/3'>
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height, width: '100%' }}
/>
</div>
</div>
);
}
+22 -14
View File
@@ -4,19 +4,20 @@ import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { Button } from '@/components/ui/button';
import { Book, Check, Copy, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
export interface DeveloperSectionProps {
className?: string;
}
/**
* Developer Section - 开发者体验展示
*/
export const DeveloperSection = React.memo(function DeveloperSection({
className,
}: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const t = useTranslations('home.developerSection');
const codeContent = `# ${t('codeComment')}
const codeContent = `# 注册用户
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
@@ -31,6 +32,7 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = codeContent;
textArea.style.position = 'fixed';
@@ -95,9 +97,7 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
</div>
<pre className='text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto'>
<code className='block'>
<span className='text-green-400'>
# {t('codeQuickStart')}
</span>
<span className='text-green-400'># 快速开始</span>
{'\n'}
<span className='text-purple-400'>curl</span> -X{' '}
<span className='text-yellow-400'>POST</span>{' '}
@@ -130,18 +130,22 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
transition={{ duration: 0.8 }}
>
<h2 className='text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6'>
{t('heading')}
开发者友好,
<br />
极速集成上手
</h2>
<p className='text-muted-foreground text-lg leading-relaxed mb-8'>
{t('description')}
标准化的 RESTful API 接口,完整的 TypeScript
类型定义,详细的文档和 Swagger
UI。无论使用什么编程语言,只需几行代码,即可快速集成。
</p>
<ul className='space-y-4 mb-8'>
{[
t('features.restful'),
t('features.swagger'),
t('features.typescript'),
t('features.examples'),
'RESTful API,语义清晰,易于理解',
'OpenAPI / Swagger 接口文档',
'TypeScript 类型定义,开发友好',
'丰富的代码示例和集成指南',
].map((item, i) => (
<li
key={i}
@@ -156,13 +160,17 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
</ul>
<div className='flex flex-wrap gap-4'>
<Link href='/docs/api'>
<Link
href='https://openflare.fyrn.link/'
target='_blank'
rel='noopener noreferrer'
>
<Button
variant='secondary'
className='rounded-full text-xs hover:bg-muted-foreground/10'
>
<Book className='w-3 h-3' />
{t('apiDocs')}
使用文档
</Button>
</Link>
</div>
+52 -46
View File
@@ -1,18 +1,34 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { Github, LucideIcon, WavesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { LucideIcon } from 'lucide-react';
// GitHub 品牌图标(lucide-react 1.x 移除了品牌图标,这里用内联 SVG 替代)
const GithubIcon = React.forwardRef<SVGSVGElement, React.ComponentProps<'svg'>>(
(props, ref) => (
<svg
ref={ref}
viewBox='0 0 24 24'
fill='currentColor'
aria-hidden='true'
{...props}
>
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
</svg>
),
);
GithubIcon.displayName = 'GithubIcon';
export interface FooterSectionProps {
className?: string;
}
/**
* Footer Section - 页脚
*/
export const FooterSection = React.memo(function FooterSection({
className,
}: FooterSectionProps) {
const t = useTranslations('home.footer');
return (
<footer
className={cn(
@@ -24,74 +40,64 @@ export const FooterSection = React.memo(function FooterSection({
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-8 mb-20'>
<div className='lg:col-span-2 space-y-6'>
<Link href='/' className='flex items-center gap-2'>
<span className='flex size-9 items-center justify-center rounded-full bg-foreground text-background [@media(max-height:700px)]:size-8'>
<WavesIcon aria-hidden='true' />
</span>
<div className='w-10 h-10 p-2 rounded bg-primary text-sm text-primary-foreground flex items-center justify-center font-bold'>
OF
</div>
<span className='text-2xl font-bold tracking-tight text-foreground'>
Wavelet
OpenFlare
</span>
</Link>
<p className='text-muted-foreground text-base leading-relaxed max-w-sm'>
{t('description')}
开源边缘节点与反向代理管理平台。基于 Go + React 构建,统一管理
OpenResty 节点、网站规则与 WAF 配置。
</p>
<div className='flex gap-4 pt-2'>
<SocialLink
icon={Github}
href='https://github.com/Rain-kl/Wavelet/'
icon={GithubIcon}
href='https://github.com/Rain-kl/OpenFlare/'
/>
</div>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>
{t('product')}
</h3>
<h3 className='font-semibold text-foreground mb-6'>产品</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/home'>{t('dashboard')}</FooterLink>
<FooterLink href='/'>仪表盘</FooterLink>
</li>
<li>
<FooterLink href='/settings'>
{t('personalSettings')}
<FooterLink href='/settings'>个人设置</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>开发</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='https://openflare.fyrn.link/'>
使用文档
</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/OpenFlare'>
源代码
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>
{t('development')}
</h3>
<h3 className='font-semibold text-foreground mb-6'>社区</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/docs/how-to-use'>
{t('quickStart')}
</FooterLink>
</li>
<li>
<FooterLink href='/docs/api'>{t('apiDocs')}</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet'>
{t('sourceCode')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>
{t('community')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/issues'>
<FooterLink href='https://github.com/Rain-kl/OpenFlare/issues'>
GitHub Issues
</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/discussions'>
{t('discussions')}
<FooterLink href='https://github.com/Rain-kl/OpenFlare/discussions'>
讨论
</FooterLink>
</li>
</ul>
@@ -105,13 +111,13 @@ export const FooterSection = React.memo(function FooterSection({
href='/docs/privacy-policy'
className='hover:text-foreground transition-colors'
>
{t('privacyPolicy')}
隐私政策
</Link>
<Link
href='/docs/terms-of-service'
className='hover:text-foreground transition-colors'
>
{t('termsOfService')}
服务条款
</Link>
</div>
</div>
+19 -15
View File
@@ -4,17 +4,17 @@ import { motion } from 'motion/react';
import { ArrowRight, CreditCard, Globe, Shield, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
export interface HeroSectionProps {
className?: string;
}
/**
* Hero Section - 首页 Hero 展示
*/
export const HeroSection = React.memo(function HeroSection({
className,
}: HeroSectionProps) {
const t = useTranslations('home.hero');
return (
<section className={cn('w-full', className)}>
<motion.div
@@ -41,9 +41,8 @@ export const HeroSection = React.memo(function HeroSection({
}}
className='text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-[1.1] mb-6 text-foreground'
>
Wavelet
<br />
<span className='bg-clip-text text-primary'>Modern Platform</span>
Modern Platform <br />
<span className='bg-clip-text text-primary'>为二次开发而生</span>
</motion.h1>
<motion.p
@@ -57,9 +56,9 @@ export const HeroSection = React.memo(function HeroSection({
}}
className='text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10'
>
{t('subtitle')}
通用的、现代化的后台管理系统脚手架
<br className='hidden md:block' />
{t('subtitleLine2')}
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
</motion.p>
<motion.div
@@ -73,23 +72,28 @@ export const HeroSection = React.memo(function HeroSection({
}}
className='flex flex-col sm:flex-row items-center gap-4'
>
<Link href='/home' className='w-full sm:w-auto'>
<Link href='/' className='w-full sm:w-auto'>
<Button
size='lg'
className='w-full rounded-full bg-primary hover:bg-primary/90 font-medium transition-all active:scale-95'
>
{t('getStarted')}
立即开始
<ArrowRight className='size-4' />
</Button>
</Link>
<Link href='/docs/how-to-use' className='w-full sm:w-auto'>
<Link
href='https://openflare.fyrn.link/'
target='_blank'
rel='noopener noreferrer'
className='w-full sm:w-auto'
>
<Button
variant='secondary'
size='lg'
className='w-full rounded-full font-medium active:scale-95'
>
{t('learnMore')}
了解更多
</Button>
</Link>
</motion.div>
@@ -103,15 +107,15 @@ export const HeroSection = React.memo(function HeroSection({
>
<div className='flex items-center gap-2'>
<Zap className='w-5 h-5 text-yellow-500' />
<span>{t('features.readyToUse')}</span>
<span>开箱即用</span>
</div>
<div className='flex items-center gap-2'>
<Globe className='w-5 h-5 text-blue-500' />
<span>{t('features.highlyExtensible')}</span>
<span>高度可扩展</span>
</div>
<div className='flex items-center gap-2'>
<Shield className='w-5 h-5 text-green-500' />
<span>{t('features.industrialGrade')}</span>
<span>工业级基建</span>
</div>
</motion.div>
</div>
+5 -15
View File
@@ -3,7 +3,6 @@ import { FileTextIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 空状态展示组件属性
@@ -56,9 +55,6 @@ export function EmptyState({
className,
iconSize = 'md',
}: EmptyStateProps) {
const t = useTranslations('layout.empty');
const displayTitle = title ?? t('noData');
const displayDescription = description ?? t('noContent');
const iconSizes = { sm: 'size-10', md: 'size-12', lg: 'size-16' };
const iconInnerSizes = { sm: 'size-5', md: 'size-6', lg: 'size-8' };
@@ -80,14 +76,10 @@ export function EmptyState({
/>
</div>
{displayTitle && (
<p className='text-base font-medium mb-1'>{displayTitle}</p>
)}
{title && <p className='text-base font-medium mb-1'>{title}</p>}
{displayDescription && (
<p className='text-sm text-muted-foreground max-w-md'>
{displayDescription}
</p>
{description && (
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
)}
{onAction && actionText && (
@@ -136,12 +128,10 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message,
message = '暂无数据',
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
const t = useTranslations('layout.empty');
const displayMessage = message ?? t('noData');
return (
<div
className={cn(
@@ -150,7 +140,7 @@ export function EmptyInline({
)}
>
<Icon className='size-4' />
<span>{displayMessage}</span>
<span>{message}</span>
</div>
);
}
+8 -13
View File
@@ -3,7 +3,6 @@ import { AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 错误展示组件属性
@@ -49,19 +48,16 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title,
title = '加载失败',
message,
error,
onRetry,
retryText,
retryText = '重试',
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const t = useTranslations('layout.error');
const displayTitle = title ?? t('loadFailed');
const displayRetryText = retryText ?? t('retry');
const errorMessage = message || error?.message || t('unknownError');
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
return (
<div
@@ -74,7 +70,7 @@ export function ErrorDisplay({
<Icon className='size-6 text-red-600 dark:text-red-400' />
</div>
<p className='text-lg font-semibold mb-2'>{displayTitle}</p>
<p className='text-lg font-semibold mb-2'>{title}</p>
<p className='text-sm text-muted-foreground max-w-md mb-4'>
{errorMessage}
@@ -82,14 +78,14 @@ export function ErrorDisplay({
{onRetry && (
<Button onClick={onRetry} variant='outline' className='mt-2'>
{displayRetryText}
{retryText}
</Button>
)}
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className='mt-6 text-left max-w-2xl w-full'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
{t('viewDetails')}
查看详细错误信息
</summary>
<pre className='mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto'>
{error.stack}
@@ -119,8 +115,7 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const t = useTranslations('layout.error');
const errorMessage = message || error?.message || t('unknownErrorShort');
const errorMessage = message || error?.message || '发生未知错误';
return (
<div
@@ -138,7 +133,7 @@ export function ErrorInline({
size='sm'
className='h-6 px-4 text-xs'
>
{t('retry')}
重试
</Button>
)}
</div>
+3 -3
View File
@@ -21,8 +21,8 @@ import { SidebarTrigger } from '@/components/ui/sidebar';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { SearchDialog } from '@/components/layout/search-dialog';
import { LanguageSwitcher } from '@/components/common/language-switcher';
import { Kbd } from '@/components/ui/kbd';
import { LanguageSwitcher } from '@/components/common/language-switcher';
/**
* 铃铛按钮组件
@@ -123,7 +123,7 @@ export function SiteHeader({
<div className='flex items-center gap-2'>
<SidebarTrigger />
<span className='text-sm font-medium truncate max-w-[120px]'>
{user?.nickname || user?.username || 'Guest'}
{user?.nickname || user?.username || t('guest')}
</span>
</div>
<div className='flex items-center gap-1'>
@@ -255,7 +255,7 @@ function AppBanner() {
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
<span className='text-foreground'>{t('bannerTitle')}</span>
<a
href='https://example.com'
href='https://openflare.fyrn.link/changelog/'
target='_blank'
className='underline underline-offset-4 hover:text-foreground'
>
+6 -11
View File
@@ -2,7 +2,6 @@ import * as React from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FileTextIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
/**
* 加载页面组件
@@ -61,7 +60,6 @@ export function AppleSpinner({
* 用于统一显示加载状态
*/
export function LoadingPage(props: { text?: string; badgeText?: string }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = props;
return (
<div className='absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center'>
@@ -89,15 +87,12 @@ interface LoadingStateProps extends React.ComponentProps<'div'> {
* 用于统一显示加载中的状态
*/
export function LoadingState({
title,
description,
title = '加载中',
description = '正在获取活动数据...',
icon: Icon = FileTextIcon,
className,
iconSize = 'md',
}: LoadingStateProps) {
const t = useTranslations('layout.loading');
const displayTitle = title ?? t('loading');
const displayDescription = description ?? t('loadingDesc');
const iconSizes = { sm: 'size-8', md: 'size-10', lg: 'size-14' };
const iconInnerSizes = { sm: 'size-4', md: 'size-5', lg: 'size-7' };
@@ -119,13 +114,13 @@ export function LoadingState({
/>
</div>
{displayTitle && (
<p className='text-sm font-medium mb-1 animate-pulse'>{displayTitle}</p>
{title && (
<p className='text-sm font-medium mb-1 animate-pulse'>{title}</p>
)}
{displayDescription && (
{description && (
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
{displayDescription}
{description}
</p>
)}
</div>
@@ -0,0 +1,170 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar';
import type { OpenFlareNavGroup } from '@/lib/navigation/openflare-nav';
import {
matchesNavPath,
openflareSidebarNav,
isNavGroupActive,
} from '@/lib/navigation/openflare-nav';
import { usePublicConfig } from '@/hooks/use-public-config';
function parseMenuDisplayConfig(
raw: string | undefined,
): Record<string, boolean> {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return {};
return Object.entries(parsed).reduce<Record<string, boolean>>(
(result, [key, value]) => {
if (typeof value === 'boolean') {
result[key] = value;
}
return result;
},
{},
);
} catch {
return {};
}
}
function SidebarNavGroupMenuItem({
group,
pathname,
onNavigate,
}: {
group: OpenFlareNavGroup;
pathname: string;
onNavigate?: () => void;
}) {
const t = useTranslations('layout');
const groupTitle = t(`groups.${group.titleKey}`);
const groupActive = isNavGroupActive(pathname, group);
const [open, setOpen] = useState(groupActive);
useEffect(() => {
if (groupActive) {
setOpen(true);
}
}, [groupActive]);
return (
<Collapsible
asChild
open={open}
onOpenChange={setOpen}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={groupTitle} isActive={groupActive}>
<group.icon />
<span>{groupTitle}</span>
<ChevronRight className='ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{group.items.map((item) => {
const title = t(`nav.${item.titleKey}`);
return (
<SidebarMenuSubItem key={item.url}>
<SidebarMenuSubButton
asChild
isActive={matchesNavPath(
pathname,
item.url,
item.childUrls,
)}
>
<Link href={item.url} onClick={onNavigate}>
<span>{title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
export function OpenFlareSidebarMenu({
onNavigate,
}: {
onNavigate?: () => void;
}) {
const pathname = usePathname();
const t = useTranslations('layout');
const { config } = usePublicConfig();
const displayConfig = useMemo(
() => parseMenuDisplayConfig(config?.menu_display_config),
[config],
);
return (
<SidebarMenu className='gap-1'>
{openflareSidebarNav.map((entry) => {
if (entry.kind === 'group') {
const filteredItems = entry.items.filter(
(item) => displayConfig[item.url] !== false,
);
if (filteredItems.length === 0) return null;
return (
<SidebarNavGroupMenuItem
key={entry.titleKey}
group={{ ...entry, items: filteredItems }}
pathname={pathname}
onNavigate={onNavigate}
/>
);
}
if (displayConfig[entry.url] === false) {
return null;
}
const title = t(`nav.${entry.titleKey}`);
return (
<SidebarMenuItem key={entry.url}>
<SidebarMenuButton
tooltip={title}
isActive={matchesNavPath(pathname, entry.url, entry.childUrls)}
asChild
>
<Link href={entry.url} onClick={onNavigate}>
<entry.icon />
<span>{title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
);
}
+3 -2
View File
@@ -74,6 +74,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter();
const { user } = useUser();
const t = useTranslations('layout.search');
const tLayout = useTranslations('layout');
const [search, setSearch] = useState('');
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
const [results, setResults] = useState<SearchItem[]>([]);
@@ -117,9 +118,9 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
}, [open, metaKey, t]);
useEffect(() => {
const items = searchItems(search, user?.is_admin);
const items = searchItems(search, user?.is_admin, (key) => tLayout(key));
setResults(items);
}, [search, user?.is_admin]);
}, [search, user?.is_admin, tLayout]);
const handleSelect = useCallback(
(item: SearchItem, openInNewTab = false) => {
+12 -46
View File
@@ -47,25 +47,22 @@ import {
ArrowUpRight,
Bell,
ChevronDown,
Code,
CreditCard,
Database,
FileQuestionMark,
FileText,
FolderOpen,
Home,
Layers,
LogOut,
MessagesSquare,
Settings,
ShieldCheck,
Terminal,
UserRound,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useUser } from '@/contexts/user-context';
import { usePublicConfig } from '@/hooks/use-public-config';
import { OpenFlareSidebarMenu } from '@/components/layout/openflare-sidebar-menu';
import { openflareSidebarNav } from '@/lib/navigation/openflare-nav';
type NavItem = {
titleKey: string;
@@ -74,33 +71,21 @@ type NavItem = {
external?: boolean;
};
const navMainItems: NavItem[] = [
{ titleKey: 'home', url: '/home', icon: Home },
{ titleKey: 'myFiles', url: '/files', icon: FolderOpen },
];
const adminItems: NavItem[] = [
{ titleKey: 'users', url: '/admin/users', icon: UserRound },
{ titleKey: 'tasks', url: '/admin/tasks', icon: Layers },
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
{ titleKey: 'database', url: '/admin/database', icon: Database },
{ titleKey: 'push', url: '/admin/push', icon: Bell },
{
titleKey: 'messageGateway',
url: '/admin/message-gateway',
icon: MessagesSquare,
},
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
// { titleKey: 'logs', url: '/admin/logs', icon: Terminal },
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
];
const documentItems: NavItem[] = [
{ titleKey: 'demo', url: '/admin/demo', icon: Code },
{ titleKey: 'apiDocs', url: '/docs/api', icon: CreditCard, external: true },
{
titleKey: 'usageDocs',
url: '/docs/how-to-use',
url: 'https://openflare.fyrn.link/',
icon: FileText,
external: true,
},
@@ -194,11 +179,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
};
const navMainFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
return navMainItems.filter((item) => displayConfig[item.url] !== false);
}, [config]);
const adminFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
return adminItems.filter((item) => displayConfig[item.url] !== false);
@@ -265,7 +245,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
<div className='flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden'>
<span className='text-xs font-medium truncate w-full text-left ml-2'>
{user?.nickname || user?.username || 'Unknown User'}
{user?.nickname || user?.username || t('user.unknownUser')}
</span>
<span className='text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2'>
{user?.is_admin ? t('user.adminRole') : t('user.userRole')}
@@ -354,7 +334,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenuSeparator className='my-2' />
<DropdownMenuItem
onClick={() => {
router.push('/docs/how-to-use');
window.open(
'https://openflare.fyrn.link/',
'_blank',
'noopener,noreferrer',
);
handleCloseSidebar();
}}
>
@@ -372,28 +356,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</DropdownMenu>
</SidebarHeader>
<SidebarContent className='group-data-[collapsible=icon]'>
{navMainFiltered.length > 0 && (
{openflareSidebarNav.length > 0 && (
<SidebarGroup className='py-0'>
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{navMainFiltered.map((item) => {
const title = t(`nav.${item.titleKey}`);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton
tooltip={title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
<OpenFlareSidebarMenu onNavigate={handleCloseSidebar} />
</SidebarGroupContent>
</SidebarGroup>
)}
@@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react';
import { useTranslations } from 'next-intl';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import enMessages from '@/messages/en.json';
import zhCNMessages from '@/messages/zh-CN.json';
import { AppIntlProvider } from './intl-provider';
function Probe() {
const t = useTranslations('common');
return <button>{t('save')}</button>;
}
const zhSave = (zhCNMessages.common as Record<string, string>).save;
const enSave = (enMessages.common as Record<string, string>).save;
describe('AppIntlProvider (static export locale resolution)', () => {
beforeEach(() => {
// Deterministic browser language (jsdom defaults to en-US).
Object.defineProperty(navigator, 'languages', {
value: ['zh-CN'],
configurable: true,
});
Object.defineProperty(navigator, 'language', {
value: 'zh-CN',
configurable: true,
});
document.documentElement.lang = 'zh-CN'; // as pre-rendered by SSR
});
afterEach(() => {
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
});
it('flips to the NEXT_LOCALE cookie locale after mount when SSR locale is stale', async () => {
// Static export pre-renders with the build-time default (zh-CN) even when
// the user previously chose en — the exact reported bug.
document.cookie = 'NEXT_LOCALE=en; Path=/';
render(
<AppIntlProvider locale='zh-CN' messages={zhCNMessages}>
<Probe />
</AppIntlProvider>,
);
expect(await screen.findByText(enSave)).toBeInTheDocument();
expect(document.documentElement.lang).toBe('en');
});
it('keeps the SSR locale when the cookie matches it', () => {
document.cookie = 'NEXT_LOCALE=zh-CN; Path=/';
render(
<AppIntlProvider locale='zh-CN' messages={zhCNMessages}>
<Probe />
</AppIntlProvider>,
);
expect(screen.getByText(zhSave)).toBeInTheDocument();
expect(document.documentElement.lang).toBe('zh-CN');
});
it('does not flip without a cookie preference', () => {
render(
<AppIntlProvider locale='zh-CN' messages={zhCNMessages}>
<Probe />
</AppIntlProvider>,
);
expect(screen.getByText(zhSave)).toBeInTheDocument();
});
});
@@ -1,9 +1,18 @@
'use client';
import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import type { AbstractIntlMessages } from 'next-intl';
import type { ReactNode } from 'react';
import { resolveClientLocale } from '@/i18n/client';
import type { AppLocale } from '@/i18n/config';
import enMessages from '@/messages/en.json';
import zhCNMessages from '@/messages/zh-CN.json';
const messagesByLocale: Record<AppLocale, AbstractIntlMessages> = {
'zh-CN': zhCNMessages,
en: enMessages,
};
type Props = {
locale: AppLocale;
@@ -11,11 +20,33 @@ type Props = {
children: ReactNode;
};
export function AppIntlProvider({ locale, messages, children }: Props) {
export function AppIntlProvider({
locale: ssrLocale,
messages: ssrMessages,
children,
}: Props) {
// Static export (NEXT_STANDALONE_EXPORT) pre-renders every page at build time
// with the default locale, so the SSR locale is stale at runtime. Re-resolve
// from the NEXT_LOCALE cookie / browser language on the client. The first
// render stays identical to SSR to avoid a hydration mismatch, then flips —
// the brief flash matches the theme-hydration pattern (suppressHydrationWarning
// is already set on <html>/<body>).
const [clientLocale, setClientLocale] = useState<AppLocale | null>(null);
useEffect(() => {
const resolved = resolveClientLocale();
if (resolved !== ssrLocale) {
setClientLocale(resolved);
document.documentElement.lang = resolved;
}
}, [ssrLocale]);
const locale = clientLocale ?? ssrLocale;
return (
<NextIntlClientProvider
locale={locale}
messages={messages}
messages={clientLocale ? messagesByLocale[clientLocale] : ssrMessages}
timeZone='Asia/Shanghai'
>
{children}
@@ -4,37 +4,31 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { ConfigService } from '@/lib/services/config';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
export function SiteTitleUpdater() {
const pathname = usePathname();
const t = useTranslations('providers.titleUpdater');
const publicConfigQuery = useQuery({
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
});
useEffect(() => {
const siteName = publicConfigQuery.data?.site_name || 'Wavelet Platform';
const siteName = publicConfigQuery.data?.site_name || 'OpenFlare';
// Determine the page suffix based on path
let suffix = '';
if (pathname === '/login') {
suffix = ` - ${t('login')}`;
suffix = ' - 登录';
} else if (pathname === '/register') {
suffix = ` - ${t('register')}`;
suffix = ' - 注册';
} else if (pathname.startsWith('/admin')) {
suffix = ` - ${t('admin')}`;
} else if (pathname === '/home') {
suffix = ` - ${t('dashboard')}`;
} else if (pathname === '/403') {
suffix = ` - ${t('forbidden')}`;
suffix = ' - 后台管理';
} else if (pathname === '/') {
suffix = '';
suffix = ' - 总览';
}
document.title = `${siteName}${suffix}`;
}, [publicConfigQuery.data?.site_name, pathname, t]);
}, [publicConfigQuery.data?.site_name, pathname]);
return null;
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+1 -1
View File
@@ -84,7 +84,7 @@ function Calendar({
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
month_grid: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+51 -42
View File
@@ -2,21 +2,26 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import type { TooltipValueType } from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
)
>;
type ChartContextProps = {
config: ChartConfig;
@@ -39,23 +44,20 @@ function ChartContainer({
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const chartId = mounted
? `chart-${id || uniqueId.replace(/:/g, '')}`
: 'chart-';
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
@@ -63,13 +65,15 @@ function ChartContainer({
data-slot='chart'
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
{mounted && <ChartStyle id={chartId} config={config} />}
<RechartsPrimitive.ResponsiveContainer>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
@@ -79,7 +83,7 @@ function ChartContainer({
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
@@ -96,7 +100,7 @@ ${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
@@ -133,7 +137,13 @@ function ChartTooltipContent({
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
'accessibilityLayer'
>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
@@ -142,11 +152,11 @@ function ChartTooltipContent({
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
@@ -181,7 +191,7 @@ function ChartTooltipContent({
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
@@ -190,15 +200,15 @@ function ChartTooltipContent({
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={item.dataKey}
key={index}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
@@ -239,12 +249,14 @@ function ChartTooltipContent({
<div className='grid gap-1.5'>
{nestLabel ? tooltipLabel : null}
<span className='text-muted-foreground'>
{itemConfig?.label || item.name}
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value && (
<span className='text-foreground font-mono font-medium tabular-nums'>
{item.value.toLocaleString()}
{item.value != null && (
<span className='font-mono font-medium text-foreground tabular-nums'>
{typeof item.value === 'number'
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
@@ -266,11 +278,10 @@ function ChartLegendContent({
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
}: React.ComponentProps<'div'> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
@@ -287,15 +298,15 @@ function ChartLegendContent({
>
{payload
.filter((item) => item.type !== 'none')
.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
key={index}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
@@ -350,9 +361,7 @@ function getPayloadConfigFromPayload(
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Trigger>) {
return (
<CollapsiblePrimitive.Trigger data-slot='collapsible-trigger' {...props} />
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) {
return (
<CollapsiblePrimitive.Content data-slot='collapsible-content' {...props} />
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+252
View File
@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { type Button, buttonVariants } from '@/components/ui/button';
import { buttonVariants, type Button } from '@/components/ui/button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
+163
View File
@@ -0,0 +1,163 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
export type TagsInputProps = {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
/** Called when a tag fails a custom validator; return true if accepted. */
validateTag?: (tag: string) => string | null;
'aria-invalid'?: boolean;
};
function normalizeTag(raw: string) {
return raw.trim();
}
function TagsInput({
value,
onChange,
placeholder = '输入后按 Enter 添加',
disabled = false,
className,
id,
validateTag,
'aria-invalid': ariaInvalid,
}: TagsInputProps) {
const [draft, setDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null);
const commit = (raw: string) => {
const tag = normalizeTag(raw);
if (!tag) {
setDraft('');
return;
}
if (validateTag) {
const error = validateTag(tag);
if (error) {
return;
}
}
if (value.includes(tag)) {
setDraft('');
return;
}
onChange([...value, tag]);
setDraft('');
};
const removeAt = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (event.key === 'Enter' || event.key === ',' || event.key === 'Tab') {
if (draft.trim()) {
event.preventDefault();
commit(draft);
}
return;
}
if (event.key === 'Backspace' && !draft && value.length > 0) {
event.preventDefault();
removeAt(value.length - 1);
}
};
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const text = event.clipboardData.getData('text');
if (!text || (!text.includes(',') && !text.includes('\n'))) {
return;
}
event.preventDefault();
const parts = text
.split(/[,\n]+/)
.map(normalizeTag)
.filter(Boolean);
if (parts.length === 0) return;
const next = [...value];
for (const part of parts) {
if (validateTag) {
const error = validateTag(part);
if (error) continue;
}
if (!next.includes(part)) {
next.push(part);
}
}
onChange(next);
setDraft('');
};
return (
<div
data-slot='tags-input'
className={cn(
'border-input dark:bg-input/30 flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-md border bg-transparent px-2 py-1 text-xs transition-[color,box-shadow] outline-none',
'focus-within:border-ring focus-within:ring-ring/30 focus-within:ring-[2px]',
ariaInvalid &&
'ring-destructive/20 dark:ring-destructive/40 border-destructive',
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
className,
)}
onClick={() => inputRef.current?.focus()}
>
{value.map((tag, index) => (
<Badge
key={`${tag}-${index}`}
variant='secondary'
className='gap-1 pr-1 font-normal'
>
<span className='max-w-[12rem] truncate'>{tag}</span>
<button
type='button'
className='hover:bg-muted rounded-sm p-0.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
aria-label={`移除 ${tag}`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
removeAt(index);
}}
>
<X className='size-3' />
</button>
</Badge>
))}
<input
ref={inputRef}
id={id}
value={draft}
disabled={disabled}
aria-invalid={ariaInvalid}
placeholder={value.length === 0 ? placeholder : undefined}
className='placeholder:text-muted-foreground min-w-[8rem] flex-1 bg-transparent py-0.5 text-xs outline-none'
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={() => {
if (draft.trim()) {
commit(draft);
}
}}
/>
</div>
);
}
export { TagsInput };