fix(frontend): keep browser API on same-origin rewrites in dev

Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
ryan
2026-07-24 16:37:24 +08:00
parent a4588b05e0
commit 9f15f42b47
75 changed files with 5971 additions and 2450 deletions
+14 -6
View File
@@ -9,6 +9,7 @@ import {
ShieldQuestion,
} from 'lucide-react';
import { getCapToken } from '@/lib/cap-solver';
import { useTranslations } from 'next-intl';
type CapStatus = 'idle' | 'solving' | 'solved' | 'error';
@@ -38,6 +39,7 @@ export function CapWidget({
const [status, setStatus] = useState<CapStatus>('idle');
const [errorMsg, setErrorMsg] = useState('');
const solving = useRef(false);
const t = useTranslations('auth.capWidget');
const solve = async () => {
if (solving.current) return;
@@ -76,7 +78,9 @@ export function CapWidget({
onClick={() => void solve()}
>
<ShieldQuestion className='size-4 shrink-0 text-muted-foreground' />
<span className='flex-1 text-muted-foreground'>点击开始人机验证</span>
<span className='flex-1 text-muted-foreground'>
{t('clickToStart')}
</span>
</button>
)}
@@ -84,14 +88,16 @@ export function CapWidget({
{status === 'idle' && autoStart && (
<>
<ShieldAlert className='size-4 shrink-0 text-muted-foreground' />
<span className='text-muted-foreground'>等待人机验证…</span>
<span className='text-muted-foreground'>
{t('waitingVerification')}
</span>
</>
)}
{status === 'solving' && (
<>
<Loader2 className='size-4 shrink-0 animate-spin text-primary' />
<span className='text-muted-foreground'>正在完成人机验证…</span>
<span className='text-muted-foreground'>{t('verifying')}</span>
</>
)}
@@ -99,7 +105,7 @@ export function CapWidget({
<>
<CheckCircle2 className='size-4 shrink-0 text-green-500' />
<span className='text-green-600 dark:text-green-400'>
人机验证通过
{t('verificationPassed')}
</span>
<ShieldCheck className='ml-auto size-4 shrink-0 text-green-500' />
</>
@@ -113,9 +119,11 @@ export function CapWidget({
>
<ShieldAlert className='size-4 shrink-0 text-destructive' />
<span className='flex-1 text-destructive'>
{errorMsg || '人机验证失败'}
{errorMsg || t('verificationFailed')}
</span>
<span className='text-xs text-muted-foreground underline'>
{t('retry')}
</span>
<span className='text-xs text-muted-foreground underline'>重试</span>
</button>
)}
</div>
+1 -1
View File
@@ -114,7 +114,7 @@ export function RegisterForm() {
toast.error(t('closed'));
router.replace('/login');
}
}, [publicConfigQuery.isSuccess, registrationEnabled, router]);
}, [publicConfigQuery.isSuccess, registrationEnabled, router, t]);
const registerMutation = useMutation({
mutationFn: (req: RegisterRequest) => {
+3 -1
View File
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react';
import { ErrorPage } from '@/components/layout/error';
import { useUser } from '@/contexts/user-context';
import { useTranslations } from 'next-intl';
type RequireAuthProps = {
children: ReactNode;
@@ -49,6 +50,7 @@ type RequireAdminAuthProps = {
/** Guards admin routes after the shared shell has rendered. */
export function RequireAdminAuth({ children }: RequireAdminAuthProps) {
const { user, loading } = useUser();
const t = useTranslations('auth.requireAuth');
if (loading) {
return (
@@ -59,7 +61,7 @@ export function RequireAdminAuth({ children }: RequireAdminAuthProps) {
}
if (!user?.is_admin) {
return <ErrorPage title='访问被拒绝' message='您没有权限访问此页面' />;
return <ErrorPage title={t('accessDenied')} message={t('noPermission')} />;
}
return <>{children}</>;
+421 -408
View File
@@ -11,62 +11,61 @@ import {
export const DOCS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
* 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>
type TFn = (key: string) => string;
<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>
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>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
<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={`{
"error_msg": "",
"data": {
"id": 1,
@@ -74,263 +73,277 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
<p className='mt-2'>{t('api.section1.failExample')}</p>
<CodeBlock
code={`{
"error_msg": "${t('api.section1.failExampleErrorMsg')}",
"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'
>
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'>
<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'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
<strong>{t('api.section1.sessionCredential')}</strong>
{t('api.section1.sessionCredentialDesc')}
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
<strong>{t('api.section1.accessTokenCredential')}</strong>
{t('api.section1.accessTokenCredentialDesc')}
</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>
</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>
),
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>
<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-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-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-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-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
登录后调用,支持普通用户权限。
<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')}
</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'
>
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-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-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={`{
<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={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -344,76 +357,75 @@ export const apiSections: 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'
>
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-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-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={`{
<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={`{
"error_msg": "",
"data": {
"site_name": "Wavelet",
@@ -423,50 +435,51 @@ export const apiSections: PolicySection[] = [
"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'
>
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 (管理员)' },
],
},
];
<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') },
],
},
];
}
+220 -243
View File
@@ -1,254 +1,231 @@
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
/**
* ------------------------------------------------------------------
* 使用指南 (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>
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>
</div>
<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>
),
},
{
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>
<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='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='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
<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
Authorization: Bearer at_628d022b7a95e26b...`}
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
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
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
language='http'
/>
</div>
</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>
),
},
];
),
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>
),
},
];
}
@@ -6,6 +6,7 @@ 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 {
@@ -32,6 +33,7 @@ export function LegalPageLayout({
damping: 30,
restDelta: 0.001,
});
const t = useTranslations('docs');
const allSectionIds = React.useMemo(() => {
return sections.flatMap((section) => [
@@ -192,7 +194,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`最后更新:${lastUpdated}`);
lines.push(`${t('lastUpdated')}:${lastUpdated}`);
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -303,7 +305,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>
@@ -318,11 +320,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' />
{sections.length} 个内容
{t('sectionCount', { count: 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' />
最后更新:{lastUpdated}
{t('lastUpdated')}:{lastUpdated}
</span>
<Button
type='button'
@@ -336,7 +338,7 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? '已复制' : '复制 Markdown'}
{hasCopied ? t('copied') : t('copyMarkdown')}
</Button>
</div>
{description && (
@@ -357,7 +359,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) => (
@@ -445,7 +447,7 @@ 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
@@ -453,7 +455,7 @@ export function LegalPageLayout({
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
{t('contactSupport')}
</Button>
</Link>
</div>
+137 -154
View File
@@ -1,160 +1,143 @@
import { type PolicySection } from './types';
/**
* ------------------------------------------------------------------
* 隐私政策 (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>
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>
</div>
</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>
),
},
{
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>
</div>
</div>
),
},
{
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</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>
),
},
];
}
+143 -151
View File
@@ -2,158 +2,150 @@ import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
* 服务条款 (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'>
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'>
<li>
<strong>危害国家安全:</strong>
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
</li>
<li>
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
攻击服务、服务器爆破等其他非法信息服务平台;
</li>
<li>
<strong>黄赌毒关联:</strong>
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
</li>
<li>
<strong>侵犯知识产权:</strong>
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
</li>
<li>
<strong>其他违法信息:</strong>
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
<strong>{t('terms.section2.liability')}</strong>
{t('terms.section2.liabilityDesc')}
</li>
</ul>
</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>
),
},
];
),
},
{
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>
),
},
];
}
@@ -5,6 +5,7 @@ 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;
@@ -25,6 +26,7 @@ export function FileImagePreview({
}: FileImagePreviewProps) {
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
const t = useTranslations('common');
React.useEffect(() => {
setFailed(false);
@@ -34,7 +36,7 @@ export function FileImagePreview({
return (
<div
role='img'
aria-label={`${alt} 加载失败`}
aria-label={`${alt} ${t('loadFailed')}`}
className={cn(
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
fallbackClassName,
@@ -47,7 +49,7 @@ export function FileImagePreview({
)}
/>
{variant === 'default' && (
<span className='text-[10px] leading-none'>加载失败</span>
<span className='text-[10px] leading-none'>{t('loadFailed')}</span>
)}
</div>
);
@@ -24,6 +24,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useTranslations } from 'next-intl';
interface ManagePageProps<T> {
title: string;
@@ -78,11 +79,12 @@ 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);
@@ -125,10 +127,10 @@ export function ManagePage<T>({
try {
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
toast.success(t('saveSuccess'));
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(t('saveFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
});
} finally {
setSaving(false);
@@ -144,7 +146,7 @@ export function ManagePage<T>({
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem);
toast.success('删除成功');
toast.success(t('deleteSuccess'));
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
@@ -152,8 +154,8 @@ export function ManagePage<T>({
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(t('deleteFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
});
}
};
@@ -164,7 +166,7 @@ export function ManagePage<T>({
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
description={loadingDescription ?? t('loading')}
/>
);
}
@@ -183,7 +185,10 @@ export function ManagePage<T>({
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription ?? t('noData')}
/>
);
}
@@ -216,7 +221,7 @@ export function ManagePage<T>({
<div className='space-y-6'>
<div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
<div className='font-semibold'>{t('configList')}</div>
</div>
{renderContent()}
</div>
@@ -239,18 +244,18 @@ export function ManagePage<T>({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
{t('confirmDeleteDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className='bg-red-600 hover:bg-red-700'
>
确认删除
{t('confirmDeleteAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -271,18 +276,24 @@ 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'>{title}</div>
<EmptyStateWithBorder icon={Layers} description={emptyDescription} />
<div className='font-semibold mb-4'>{displayTitle}</div>
<EmptyStateWithBorder
icon={Layers}
description={displayEmptyDescription}
/>
</div>
);
}
@@ -291,7 +302,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'>{title}</div>
<div className='font-semibold'>{displayTitle}</div>
{onSave && (
<Button
onClick={onSave}
@@ -301,10 +312,10 @@ export function ManageDetailPanel({
>
{saving ? (
<>
<Spinner /> 更新中
<Spinner /> {t('updating')}
</>
) : (
'更新'
t('update')
)}
</Button>
)}
@@ -341,6 +352,7 @@ 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'>
@@ -358,7 +370,7 @@ export function ManageTable<T>({
))}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
{t('actions')}
</TableHead>
)}
</TableRow>
@@ -16,6 +16,7 @@ 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;
@@ -36,10 +37,13 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
title,
description,
}: 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) {
@@ -58,8 +62,8 @@ export function PasswordDialog({
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className='w-xs sm:w-md'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
<DialogTitle>{displayTitle}</DialogTitle>
<DialogDescription>{displayDescription}</DialogDescription>
</DialogHeader>
<div className='py-2 flex justify-center'>
@@ -92,7 +96,7 @@ export function PasswordDialog({
disabled={loading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
type='button'
@@ -103,10 +107,10 @@ export function PasswordDialog({
{loading ? (
<>
<Spinner className='mr-2 h-4 w-4' />
验证中...
{t('verifying')}
</>
) : (
'确认'
t('confirm')
)}
</Button>
</div>
+28 -28
View File
@@ -17,15 +17,17 @@ 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: '个人资料',
description: '管理您的个人账户信息及个性化配置',
title: t('quickLinks.profile.title'),
description: t('quickLinks.profile.description'),
icon: User,
url: '/settings/profile',
color: 'text-blue-500',
@@ -33,8 +35,8 @@ export function HomeMain() {
borderColor: 'hover:border-blue-500/30',
},
{
title: '开发接口文档',
description: '查看开放平台的 RESTful 接口规格说明',
title: t('quickLinks.apiDocs.title'),
description: t('quickLinks.apiDocs.description'),
icon: FileText,
url: '/docs/api',
color: 'text-emerald-500',
@@ -43,8 +45,8 @@ export function HomeMain() {
external: true,
},
{
title: '使用文档',
description: '学习如何集成 API 及日常操作帮助指南',
title: t('quickLinks.userDocs.title'),
description: t('quickLinks.userDocs.description'),
icon: HelpCircle,
url: '/docs/how-to-use',
color: 'text-purple-500',
@@ -56,8 +58,8 @@ export function HomeMain() {
const adminLinks = [
{
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
title: t('adminLinks.settings.title'),
description: t('adminLinks.settings.description'),
icon: Shield,
url: '/admin/settings',
color: 'text-amber-500',
@@ -65,8 +67,8 @@ export function HomeMain() {
borderColor: 'hover:border-amber-500/30',
},
{
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
title: t('adminLinks.systemConfig.title'),
description: t('adminLinks.systemConfig.description'),
icon: ShieldCheck,
url: '/admin/system',
color: 'text-indigo-500',
@@ -74,8 +76,8 @@ export function HomeMain() {
borderColor: 'hover:border-indigo-500/30',
},
{
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
title: t('adminLinks.userManagement.title'),
description: t('adminLinks.userManagement.description'),
icon: User,
url: '/admin/users',
color: 'text-rose-500',
@@ -83,8 +85,8 @@ export function HomeMain() {
borderColor: 'hover:border-rose-500/30',
},
{
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
title: t('adminLinks.tasks.title'),
description: t('adminLinks.tasks.description'),
icon: Layers,
url: '/admin/tasks',
color: 'text-teal-500',
@@ -92,8 +94,8 @@ export function HomeMain() {
borderColor: 'hover:border-teal-500/30',
},
{
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
title: t('adminLinks.logs.title'),
description: t('adminLinks.logs.description'),
icon: Terminal,
url: '/admin/logs',
color: 'text-cyan-500',
@@ -104,7 +106,6 @@ export function HomeMain() {
return (
<div className='py-6 space-y-8 max-w-6xl mx-auto'>
{/* 积分收益提示 banner */}
<AnimatePresence>
{isBannerVisible && (
<motion.div
@@ -120,8 +121,7 @@ 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 +129,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'>开始使用</Link>
<Link href='/home'>{t('banner.getStarted')}</Link>
</Button>
<Button
size='sm'
@@ -137,7 +137,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,9 +146,10 @@ export function HomeMain() {
)}
</AnimatePresence>
{/* 快捷导航 */}
<div className='space-y-4'>
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{t('navigation.title')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{quickLinks.map((link, idx) => (
<motion.div
@@ -190,7 +191,7 @@ export function HomeMain() {
target='_blank'
rel='noopener noreferrer'
>
立即跳转 <ArrowRight className='size-3 ml-1' />
{t('jumpNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
) : (
@@ -200,7 +201,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -211,12 +212,11 @@ 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 +251,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -39,6 +40,7 @@ export function AuthSourceModal({
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const t = useTranslations('settings.authSourceModal');
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -74,10 +76,12 @@ export function AuthSourceModal({
await services.adminAuthSource.createAuthSource(form);
}
await onChanged();
toast.success('认证源已保存');
toast.success(t('authSourceSaved'));
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存认证源失败');
toast.error(
error instanceof Error ? error.message : t('saveAuthSourceFailed'),
);
} finally {
setSaving(false);
}
@@ -87,17 +91,17 @@ export function AuthSourceModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-xl'>
<DialogHeader>
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
<DialogTitle>
{source ? t('editAuthSource') : t('addAuthSource')}
</DialogTitle>
<DialogDescription>
{source
? '修改系统自定义的 OIDC 认证源参数。'
: '配置新的自定义 OIDC 认证源。'}
{source ? t('editAuthSourceDesc') : t('addAuthSourceDesc')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 md:grid-cols-2 pt-2'>
<div className='space-y-2'>
<Label>标识符 (英文名称)</Label>
<Label>{t('identifier')}</Label>
<Input
value={form.name}
disabled={!!source}
@@ -108,7 +112,7 @@ export function AuthSourceModal({
/>
</div>
<div className='space-y-2'>
<Label>展示名称</Label>
<Label>{t('displayName')}</Label>
<Input
value={form.display_name}
onChange={(e) =>
@@ -133,11 +137,11 @@ export function AuthSourceModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
}
placeholder={source ? '留空则保留原值' : ''}
placeholder={source ? t('keepOriginalIfEmpty') : ''}
/>
</div>
<div className='space-y-2 md:col-span-2'>
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Label>{t('discoveryURL')}</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) =>
@@ -159,7 +163,7 @@ export function AuthSourceModal({
/>
</div>
<div className='space-y-2'>
<Label>图标 URL (可选)</Label>
<Label>{t('iconURL')}</Label>
<Input
value={form.icon_url}
onChange={(e) =>
@@ -170,9 +174,9 @@ export function AuthSourceModal({
</div>
<div className='flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10'>
<div>
<div className='font-medium text-sm'>启用认证源</div>
<div className='font-medium text-sm'>{t('enableAuthSource')}</div>
<div className='text-xs text-muted-foreground'>
启用后会立即显示在登录页和账号绑定中。
{t('enableAuthSourceDesc')}
</div>
</div>
<Switch
@@ -184,7 +188,7 @@ export function AuthSourceModal({
</div>
<div className='md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
{t('cancel')}
</Button>
<Button
type='button'
@@ -192,7 +196,7 @@ export function AuthSourceModal({
disabled={saving}
variant='secondary'
>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</Button>
</div>
</div>
@@ -3,6 +3,7 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import services from '@/lib/services';
import {
@@ -39,6 +40,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
}
export function InfoTab() {
const t = useTranslations('settings.info');
const updateQuery = useQuery({
queryKey: ['admin', 'update'],
queryFn: () => services.adminStatus.getUpdateStatus(),
@@ -49,10 +51,10 @@ export function InfoTab() {
const applyUpdateMutation = useMutation({
mutationFn: () => services.adminStatus.applyUpdate(),
onSuccess: () => {
toast.success('升级包已校验完成,服务正在重启');
toast.success(t('upgradeVerified'));
},
onError: (error: Error) => {
toast.error(error.message || '应用升级失败');
toast.error(error.message || t('applyUpgradeFailed'));
},
});
@@ -69,17 +71,17 @@ export function InfoTab() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
应用更新
{t('appUpdate')}
</CardTitle>
<CardDescription className='text-xs'>
检查上游 GitHub Actions Release 并升级当前服务
{t('appUpdateDesc')}
</CardDescription>
</div>
</div>
{update?.update_available ? (
<Badge>发现新版本</Badge>
<Badge>{t('newVersionAvailable')}</Badge>
) : update ? (
<Badge variant='secondary'>已是最新</Badge>
<Badge variant='secondary'>{t('alreadyLatest')}</Badge>
) : null}
</div>
</CardHeader>
@@ -91,7 +93,7 @@ export function InfoTab() {
) : updateQuery.isError ? (
<div className='flex min-h-32 flex-col items-center justify-center gap-3 text-center'>
<p className='text-xs text-muted-foreground'>
{updateQuery.error.message || '无法获取上游版本信息'}
{updateQuery.error.message || t('cannotGetVersion')}
</p>
<Button
type='button'
@@ -100,25 +102,34 @@ export function InfoTab() {
onClick={() => updateQuery.refetch()}
>
<RefreshCw data-icon='inline-start' />
重新检查
{t('recheck')}
</Button>
</div>
) : update ? (
<>
<div>
<InfoRow label='当前版本' value={update.current_version} />
<InfoRow label='最新版本' value={update.latest_version} />
<InfoRow
label={t('currentVersion')}
value={update.current_version}
/>
<InfoRow
label={t('latestVersion')}
value={update.latest_version}
/>
{update.build_time && (
<InfoRow label='构建时间' value={update.build_time} />
<InfoRow label={t('buildTime')} value={update.build_time} />
)}
<InfoRow label='运行平台' value={update.platform} />
<InfoRow label='上游仓库' value={update.upstream_repository} />
<InfoRow label='Release 资产' value={update.asset_name} />
<InfoRow label={t('platform')} value={update.platform} />
<InfoRow
label={t('upstreamRepo')}
value={update.upstream_repository}
/>
<InfoRow label={t('releaseAsset')} value={update.asset_name} />
</div>
{update.release_notes && (
<div className='flex flex-col gap-2 rounded-md border border-dashed p-3'>
<p className='text-xs font-medium'>更新说明</p>
<p className='text-xs font-medium'>{t('releaseNotes')}</p>
<p className='max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground'>
{update.release_notes}
</p>
@@ -138,7 +149,7 @@ export function InfoTab() {
) : (
<RefreshCw data-icon='inline-start' />
)}
检查更新
{t('checkUpdate')}
</Button>
{update.release_url && (
<Button asChild variant='outline' size='sm'>
@@ -148,7 +159,7 @@ export function InfoTab() {
rel='noreferrer'
>
<ExternalLink data-icon='inline-start' />
查看 Release
{t('viewRelease')}
</a>
</Button>
)}
@@ -164,25 +175,24 @@ export function InfoTab() {
{applyUpdateMutation.isPending && (
<Spinner data-icon='inline-start' />
)}
立即升级
{t('upgradeNow')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
升级到 {update.latest_version}?
{t('upgradeTo', { version: update.latest_version })}
</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name}
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
{t('upgradeDesc', { asset: update.asset_name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => applyUpdateMutation.mutate()}
>
确认升级
{t('confirmUpgrade')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -192,10 +202,10 @@ export function InfoTab() {
{!update.can_upgrade && (
<p className='text-xs text-muted-foreground'>
{update.current_version === 'dev'
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
? t('devBuildNoUpgrade')
: update.update_available
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
: '当前版本无需升级。'}
? t('platformNotSupported')
: t('noUpgradeNeeded')}
</p>
)}
</>
@@ -27,6 +27,7 @@ import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { TemplatesManager } from './templates';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface OperationTabProps {
configs: Record<string, SystemConfig>;
@@ -38,6 +39,7 @@ export function OperationTab({
systemConfigsQuery,
}: OperationTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.operation');
const uploadTypesQuery = useQuery({
queryKey: ['admin', 'upload-types'],
@@ -63,10 +65,10 @@ export function OperationTab({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('文件访问白名单已更新');
toast.success(t('whitelistUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新白名单失败');
toast.error(error.message || t('updateWhitelistFailed'));
},
});
@@ -120,10 +122,10 @@ export function OperationTab({
</div>
<div>
<CardTitle className='text-base font-semibold'>
文件访问权限控制
{t('fileAccessControl')}
</CardTitle>
<CardDescription className='text-xs'>
配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
{t('fileAccessControlDesc')}
</CardDescription>
</div>
</div>
@@ -132,7 +134,7 @@ export function OperationTab({
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-muted-foreground'>
添加免鉴权类型:
{t('addAuthFreeType')}
</span>
<Select
value=''
@@ -144,7 +146,7 @@ export function OperationTab({
}
>
<SelectTrigger className='w-[200px]' size='sm'>
<SelectValue placeholder='选择业务类型...' />
<SelectValue placeholder={t('selectBusinessType')} />
</SelectTrigger>
<SelectContent>
{availableTypes
@@ -158,7 +160,7 @@ export function OperationTab({
(t) => !currentWhitelist.includes(t.value),
).length === 0 && (
<div className='text-xs text-muted-foreground p-2 text-center'>
所有类型已添加
{t('allTypesAdded')}
</div>
)}
</SelectContent>
@@ -170,7 +172,7 @@ export function OperationTab({
<div className='flex items-center gap-2'>
<ShieldAlert className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
当前免鉴权列表
{t('currentAuthFreeList')}
</span>
</div>
@@ -200,7 +202,7 @@ export function OperationTab({
</div>
) : (
<p className='text-xs text-muted-foreground'>
白名单已空,所有类型文件的访问都将需要登录。
{t('whitelistEmpty')}
</p>
)}
</div>
@@ -30,6 +30,7 @@ import { Switch } from '@/components/ui/switch';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface MenuItem {
path: string;
@@ -147,6 +148,7 @@ interface OtherTabProps {
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.other');
const menuDisplayConfig = useMemo(() => {
const raw = configs['menu_display_config']?.value;
@@ -183,10 +185,10 @@ export function OtherTab({ configs }: OtherTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('目录显示配置已更新');
toast.success(t('menuDisplayUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -203,10 +205,10 @@ export function OtherTab({ configs }: OtherTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
目录显示管理
{t('menuDisplayManagement')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
{t('menuDisplayManagementDesc')}
</CardDescription>
</div>
</div>
@@ -241,7 +243,7 @@ export function OtherTab({ configs }: OtherTabProps) {
</span>
{isReadOnly && (
<span className='text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0'>
不可隐藏
{t('notHideable')}
</span>
)}
</div>
@@ -270,8 +272,10 @@ export function OtherTab({ configs }: OtherTabProps) {
<div className='p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5'>
<Info className='size-4 text-primary shrink-0 mt-0.5' />
<div className='text-xs text-muted-foreground leading-relaxed'>
<span className='font-semibold text-foreground'>安全提示:</span>
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
<span className='font-semibold text-foreground'>
{t('securityTipTitle')}
</span>
{t('securityTipDesc')}
</div>
</div>
</CardContent>
+96 -87
View File
@@ -64,10 +64,12 @@ import {
ImageCropReset,
} from '@/components/ui/image-crop';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
export function ProfileMain() {
const { user, loading, refetch } = useUser();
const controls = useAnimation();
const t = useTranslations('settings.profile');
const isAnimatingRef = React.useRef(false);
const queryClient = useQueryClient();
@@ -109,25 +111,25 @@ export function ProfileMain() {
const changePasswordMutation = useMutation({
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
onSuccess: () => {
toast.success('密码修改成功');
toast.success(t('passwordChangeSuccess'));
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
void refetch();
},
onError: (error: Error) => {
toast.error(error.message || '修改密码失败,请重试');
toast.error(error.message || t('passwordChangeFailed'));
},
});
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error('两次输入的新密码不一致');
toast.error(t('passwordMismatch'));
return;
}
if (newPassword.length < 8) {
toast.error('新密码长度不能少于 8 位');
toast.error(t('passwordTooShort'));
return;
}
changePasswordMutation.mutate({
@@ -139,12 +141,12 @@ export function ProfileMain() {
const updateProfileMutation = useMutation({
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
onSuccess: () => {
toast.success('个人信息修改成功');
toast.success(t('profileUpdateSuccess'));
setIsEditDialogOpen(false);
void refetch();
},
onError: (error: Error) => {
toast.error(error.message || '修改个人信息失败,请重试');
toast.error(error.message || t('profileUpdateFailed'));
},
});
@@ -185,7 +187,7 @@ export function ProfileMain() {
window.location.href = authorize_url;
},
onError: (error: Error) => {
toast.error(error.message || '绑定认证源失败');
toast.error(error.message || t('bindSourceFailed'));
},
});
@@ -221,22 +223,22 @@ export function ProfileMain() {
setAvatarUrl(`/f/${res.id}`);
setIsCropDialogOpen(false);
setCropFile(null);
toast.success('头像上传成功,点击保存以生效');
toast.success(t('avatarUploadSuccess'));
} catch (err) {
toast.error((err as Error).message || '上传头像失败');
toast.error((err as Error).message || t('avatarUploadFailed'));
}
};
const getGenderLabel = (g?: string) => {
switch (g) {
case 'male':
return '男';
return t('genderMale');
case 'female':
return '女';
return t('genderFemale');
case 'secret':
return '保密';
return t('genderSecret');
default:
return g || '保密';
return g || t('genderSecret');
}
};
@@ -244,7 +246,7 @@ export function ProfileMain() {
return (
<div className='py-6 space-y-4 max-w-4xl mx-auto'>
<div className='border-b border-border pb-4'>
<h1 className='text-2xl font-semibold'>个人资料</h1>
<h1 className='text-2xl font-semibold'>{t('title')}</h1>
</div>
</div>
);
@@ -253,7 +255,7 @@ export function ProfileMain() {
if (!user) {
return (
<div className='py-6 space-y-6 max-w-4xl mx-auto'>
<div className='text-sm text-muted-foreground'>未找到用户信息</div>
<div className='text-sm text-muted-foreground'>{t('userNotFound')}</div>
</div>
);
}
@@ -266,14 +268,14 @@ export function ProfileMain() {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href='/settings' className='text-base text-primary'>
设置
{t('breadcrumbSettings')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className='text-base font-semibold'>
个人资料
{t('breadcrumbProfile')}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
@@ -284,9 +286,11 @@ export function ProfileMain() {
<div className='space-y-6 bg-card border border-dashed rounded-lg p-6'>
<div className='border-b pb-4 flex justify-between items-center'>
<div>
<h2 className='text-lg font-semibold tracking-tight'>基本资料</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{t('basicInfo')}
</h2>
<p className='text-xs text-muted-foreground'>
您的个人账户基本信息
{t('basicInfoDesc')}
</p>
</div>
<Button
@@ -296,7 +300,7 @@ export function ProfileMain() {
onClick={() => setIsEditDialogOpen(true)}
>
<Edit className='size-3.5 mr-1.5' />
编辑资料
{t('editProfile')}
</Button>
</div>
@@ -323,7 +327,7 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
账户
{t('account')}
</div>
<div className='text-sm font-semibold'>@{user.username}</div>
</div>
@@ -331,17 +335,17 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
昵称
{t('nickname')}
</div>
<div className='text-sm font-semibold'>
{user.nickname || '未设置'}
{user.nickname || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Info className='size-3 text-muted-foreground/70' />
用户ID (UID)
{t('uid')}
</div>
<div className='text-sm font-mono font-semibold'>{user.id}</div>
</div>
@@ -349,27 +353,27 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Mail className='size-3 text-muted-foreground/70' />
邮箱
{t('email')}
</div>
<div className='text-sm font-semibold truncate max-w-[220px]'>
{user.email || '未绑定'}
{user.email || t('notBound')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Phone className='size-3 text-muted-foreground/70' />
手机号码
{t('phone')}
</div>
<div className='text-sm font-semibold'>
{user.phone || '未设置'}
{user.phone || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
性别
{t('gender')}
</div>
<div className='text-sm font-semibold'>
{getGenderLabel(user.gender)}
@@ -379,7 +383,7 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Globe className='size-3 text-muted-foreground/70' />
个人网站
{t('website')}
</div>
<div className='text-sm font-semibold truncate max-w-[220px]'>
{user.website ? (
@@ -396,7 +400,7 @@ export function ProfileMain() {
{user.website}
</a>
) : (
'未设置'
t('notSet')
)}
</div>
</div>
@@ -404,25 +408,26 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<MapPin className='size-3 text-muted-foreground/70' />
所在地
{t('location')}
</div>
<div className='text-sm font-semibold'>
{user.location || '未设置'}
{user.location || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Shield className='size-3 text-muted-foreground/70' />
管理员身份
{t('isAdmin')}
</div>
<div className='text-sm font-semibold flex items-center gap-1'>
{user.is_admin ? (
<span className='text-rose-600 flex items-center gap-1'>
<Shield className='size-3.5' />是
<Shield className='size-3.5' />
{t('yes')}
</span>
) : (
<span>否</span>
<span>{t('no')}</span>
)}
</div>
</div>
@@ -433,10 +438,10 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<BookOpen className='size-3 text-muted-foreground/70' />
个人简介
{t('bio')}
</div>
<div className='text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3'>
{user.bio || '这个人很懒,什么都没有留下。'}
{user.bio || t('bioDefault')}
</div>
</div>
</div>
@@ -454,10 +459,10 @@ export function ProfileMain() {
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>
修改密码
{t('changePassword')}
</h2>
<p className='text-[11px] text-muted-foreground'>
更改您的账号密码以确保安全。密码长度不能少于 8 位。
{t('changePasswordDesc')}
</p>
</div>
</div>
@@ -466,9 +471,9 @@ export function ProfileMain() {
<div className='mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5'>
<Info className='size-4 shrink-0 mt-0.5' />
<div>
<p className='font-semibold'>密码风险提示</p>
<p className='font-semibold'>{t('passwordRiskWarning')}</p>
<p className='mt-0.5 text-amber-500/80 leading-relaxed font-normal'>
为了账号安全,您必须修改初始密码。
{t('mustChangePassword')}
</p>
</div>
</div>
@@ -477,11 +482,11 @@ export function ProfileMain() {
<form onSubmit={handlePasswordChange} className='space-y-3 pt-4'>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
当前密码
{t('currentPassword')}
</label>
<Input
type='password'
placeholder='请输入当前密码'
placeholder={t('currentPasswordPlaceholder')}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -490,11 +495,11 @@ export function ProfileMain() {
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
新密码
{t('newPassword')}
</label>
<Input
type='password'
placeholder='新密码(至少 8 位)'
placeholder={t('newPasswordPlaceholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -503,11 +508,11 @@ export function ProfileMain() {
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
确认新密码
{t('confirmNewPassword')}
</label>
<Input
type='password'
placeholder='确认新密码'
placeholder={t('confirmNewPasswordPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -522,7 +527,9 @@ export function ProfileMain() {
className='w-full sm:w-auto h-8 text-xs'
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? '提交中...' : '确认修改'}
{changePasswordMutation.isPending
? t('submitting')
: t('confirmChange')}
</Button>
</div>
</form>
@@ -538,10 +545,10 @@ export function ProfileMain() {
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>
第三方账号绑定
{t('thirdPartyBinding')}
</h2>
<p className='text-[11px] text-muted-foreground'>
管理并关联您的第三方授权账户,便于快捷登录与验证
{t('thirdPartyBindingDesc')}
</p>
</div>
</div>
@@ -549,7 +556,7 @@ export function ProfileMain() {
{/* 已绑定账号列表 */}
<div className='space-y-2 pt-4'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
已绑定账号
{t('boundAccounts')}
</h3>
{externalAccountBindingsQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
@@ -569,7 +576,7 @@ export function ProfileMain() {
<span className='text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]'>
{binding.external_username ||
binding.email ||
'未提供账号标识'}
t('noAccountIdentifier')}
</span>
</div>
<Button
@@ -584,11 +591,11 @@ export function ProfileMain() {
await queryClient.invalidateQueries({
queryKey: ['auth', 'external-accounts'],
});
toast.success('绑定已移除');
toast.success(t('bindingRemoved'));
}}
>
<Unlink className='size-3 mr-1' />
解绑
{t('unbind')}
</Button>
</div>
))}
@@ -596,7 +603,7 @@ export function ProfileMain() {
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center'>
<Link2 className='size-5 text-muted-foreground/30 mb-1' />
暂无绑定的第三方账号
{t('noBoundAccounts')}
</div>
)}
</div>
@@ -606,7 +613,7 @@ export function ProfileMain() {
{/* 绑定新账号列表 */}
<div className='space-y-2'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
绑定新账号
{t('bindNewAccount')}
</h3>
{publicAuthSourcesQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
@@ -626,7 +633,11 @@ export function ProfileMain() {
>
<div className='flex items-center gap-1.5'>
<Link2 className='size-3 text-muted-foreground group-hover:text-primary' />
<span>绑定 {source.display_name || source.name}</span>
<span>
{t('bindSource', {
name: source.display_name || source.name,
})}
</span>
</div>
<ArrowRight className='size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-primary' />
</Button>
@@ -634,7 +645,7 @@ export function ProfileMain() {
</div>
) : (
<div className='text-[11px] text-muted-foreground text-center py-2'>
暂无可用第三方认证源
{t('noAvailableSources')}
</div>
)}
</div>
@@ -646,10 +657,8 @@ export function ProfileMain() {
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>
修改您的基本信息,保存后立即生效。
</DialogDescription>
<DialogTitle>{t('editProfileTitle')}</DialogTitle>
<DialogDescription>{t('editProfileDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleProfileSubmit} className='space-y-4'>
@@ -670,7 +679,7 @@ export function ProfileMain() {
</div>
</div>
<span className='text-[10px] text-muted-foreground'>
点击上传/修改头像
{t('uploadAvatar')}
</span>
<input
type='file'
@@ -684,21 +693,21 @@ export function ProfileMain() {
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<Label htmlFor='nickname' className='text-xs'>
昵称
{t('nickname')}
</Label>
<Input
id='nickname'
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入您的昵称'
placeholder={t('nicknamePlaceholder')}
required
/>
</div>
<div className='space-y-1'>
<Label htmlFor='email' className='text-xs'>
邮箱
{t('email')}
</Label>
<Input
id='email'
@@ -706,26 +715,26 @@ export function ProfileMain() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入您的邮箱'
placeholder={t('emailPlaceholder')}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='phone' className='text-xs'>
手机号码
{t('phone')}
</Label>
<Input
id='phone'
value={phone}
onChange={(e) => setPhone(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入手机号'
placeholder={t('phonePlaceholder')}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='gender' className='text-xs'>
性别
{t('gender')}
</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger
@@ -733,19 +742,19 @@ export function ProfileMain() {
size='sm'
className='w-full text-xs h-8'
>
<SelectValue placeholder='选择性别' />
<SelectValue placeholder={t('genderPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='male'>男</SelectItem>
<SelectItem value='female'>女</SelectItem>
<SelectItem value='secret'>保密</SelectItem>
<SelectItem value='male'>{t('genderMale')}</SelectItem>
<SelectItem value='female'>{t('genderFemale')}</SelectItem>
<SelectItem value='secret'>{t('genderSecret')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<Label htmlFor='website' className='text-xs'>
个人网站
{t('website')}
</Label>
<Input
id='website'
@@ -758,28 +767,28 @@ export function ProfileMain() {
<div className='space-y-1'>
<Label htmlFor='location' className='text-xs'>
所在地
{t('location')}
</Label>
<Input
id='location'
value={location}
onChange={(e) => setLocation(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='如:北京'
placeholder={t('locationPlaceholder')}
/>
</div>
</div>
<div className='space-y-1'>
<Label htmlFor='bio' className='text-xs'>
个人简介
{t('bio')}
</Label>
<Textarea
id='bio'
value={bio}
onChange={(e) => setBio(e.target.value)}
className='text-xs min-h-[60px] rounded-lg'
placeholder='介绍一下自己吧...'
placeholder={t('bioPlaceholder')}
/>
</div>
@@ -791,7 +800,7 @@ export function ProfileMain() {
className='h-8 text-xs'
onClick={() => setIsEditDialogOpen(false)}
>
取消
{t('cancel')}
</Button>
<Button
type='submit'
@@ -799,7 +808,9 @@ export function ProfileMain() {
className='h-8 text-xs'
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? '保存中...' : '保存修改'}
{updateProfileMutation.isPending
? t('saving')
: t('saveChanges')}
</Button>
</DialogFooter>
</form>
@@ -810,10 +821,8 @@ export function ProfileMain() {
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>裁剪头像</DialogTitle>
<DialogDescription>
调整头像位置和大小,使其处于居中圆形区域内。
</DialogDescription>
<DialogTitle>{t('cropAvatar')}</DialogTitle>
<DialogDescription>{t('cropAvatarDesc')}</DialogDescription>
</DialogHeader>
{cropFile && (
@@ -839,7 +848,7 @@ export function ProfileMain() {
setCropFile(null);
}}
>
取消
{t('cancel')}
</Button>
</DialogFooter>
</DialogContent>
@@ -44,43 +44,43 @@ import { AuthSourceModal } from '@/components/common/settings/auth-source-modal'
import services from '@/lib/services';
import type { AuthSource, SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
const SECURITY_KEYS = [
{
key: 'password_login_enabled',
title: '允许密码登录',
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
titleKey: 'passwordLoginEnabled',
descKey: 'passwordLoginEnabledDesc',
icon: Lock,
},
{
key: 'registration_enabled',
title: '允许注册',
description: '关闭后系统将禁止新用户进行自主账号注册。',
titleKey: 'registrationEnabled',
descKey: 'registrationEnabledDesc',
icon: UserPlus,
},
{
key: 'password_register_enabled',
title: '允许密码注册',
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
titleKey: 'passwordRegisterEnabled',
descKey: 'passwordRegisterEnabledDesc',
icon: Fingerprint,
},
{
key: 'oidc_login_enabled',
title: '允许 OIDC 登录',
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
titleKey: 'oidcLoginEnabled',
descKey: 'oidcLoginEnabledDesc',
icon: Globe,
},
{
key: 'email_login_verification_enabled',
title: '邮箱登录验证',
description:
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
titleKey: 'emailLoginVerification',
descKey: 'emailLoginVerificationDesc',
icon: Mail,
},
{
key: 'email_register_verification_enabled',
title: '邮箱注册验证',
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
titleKey: 'emailRegisterVerification',
descKey: 'emailRegisterVerificationDesc',
icon: Mail,
},
] as const;
@@ -92,6 +92,7 @@ interface SecurityTabProps {
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.security');
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
@@ -155,14 +156,14 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('登录状态保持时间已更新');
toast.success(t('loginSessionTTLUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
const handleTTLChange = (val: string) => {
const handleSessionTTLChange = (val: string) => {
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
@@ -172,7 +173,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const handleCustomBlur = () => {
const parsed = parseInt(customHours, 10);
if (isNaN(parsed) || parsed <= 0) {
toast.error('请输入有效的过期小时数(正整数)');
toast.error(t('invalidHours'));
// 重置为原本的值
const originalVal = configs['login_session_ttl_hours']?.value || '0';
setCustomHours(
@@ -202,10 +203,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('系统安全配置已更新');
toast.success(t('securityConfigUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -220,10 +221,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源状态已更新');
toast.success(t('authSourceStatusUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '切换状态失败');
toast.error(error.message || t('toggleStatusFailed'));
},
});
@@ -236,10 +237,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源已删除');
toast.success(t('authSourceDeleted'));
},
onError: (error: Error) => {
toast.error(error.message || '删除认证源失败');
toast.error(error.message || t('deleteAuthSourceFailed'));
},
});
@@ -270,14 +271,14 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('人机验证配置已成功保存');
toast.success(t('captchaConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleCapSave = (e: React.FormEvent) => {
const handleCapSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCapMutation.mutate();
};
@@ -293,10 +294,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
系统登录/注册设置
{t('loginRegistrationSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统的登录限制与用户自主注册权限
{t('loginRegistrationSettingsDesc')}
</CardDescription>
</div>
</div>
@@ -316,11 +317,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<div className='flex items-center gap-2'>
{Icon && <Icon className='size-4 text-primary' />}
<span className='font-medium text-sm text-foreground'>
{item.title}
{t(item.titleKey)}
</span>
</div>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
{item.description}
{t(item.descKey)}
</p>
</div>
<Switch
@@ -338,11 +339,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<div className='flex items-center gap-2'>
<Clock className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
登录状态保持时间
{t('loginSessionTTL')}
</span>
</div>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
{t('loginSessionTTLDesc')}
</p>
</div>
@@ -350,17 +351,19 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<Select
value={sessionTTL}
disabled={updateTTLMutation.isPending}
onValueChange={handleTTLChange}
onValueChange={handleSessionTTLChange}
>
<SelectTrigger className='w-[180px] bg-card border-dashed text-xs h-8'>
<SelectValue placeholder='选择保留时间' />
<SelectValue placeholder={t('selectRetentionTime')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='0'>关闭 (浏览器关闭自动退登)</SelectItem>
<SelectItem value='168'>7 天</SelectItem>
<SelectItem value='720'>30 天</SelectItem>
<SelectItem value='-1'>永不过期</SelectItem>
<SelectItem value='custom'>自定义时长</SelectItem>
<SelectItem value='0'>{t('sessionOff')}</SelectItem>
<SelectItem value='168'>{t('session7Days')}</SelectItem>
<SelectItem value='720'>{t('session30Days')}</SelectItem>
<SelectItem value='-1'>
{t('sessionNeverExpire')}
</SelectItem>
<SelectItem value='custom'>{t('sessionCustom')}</SelectItem>
</SelectContent>
</Select>
@@ -376,7 +379,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
handleCustomBlur();
}
}}
placeholder='小时'
placeholder={t('hoursPlaceholder')}
disabled={updateTTLMutation.isPending}
className='w-20 bg-card border-dashed text-xs h-8 px-2'
/>
@@ -396,10 +399,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
认证源管理
{t('authSourceManagement')}
</CardTitle>
<CardDescription className='text-xs'>
添加、修改并启用系统自定义的 OIDC 认证源
{t('authSourceManagementDesc')}
</CardDescription>
</div>
</div>
@@ -413,7 +416,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
variant='secondary'
>
<Plus className='mr-1.5 size-3.5' />
新增认证源
{t('addAuthSource')}
</Button>
</CardHeader>
<CardContent className='pt-6 space-y-3'>
@@ -439,11 +442,12 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}`}
>
{source.is_active ? '已启用' : '已禁用'}
{source.is_active ? t('enabled') : t('disabled')}
</span>
</div>
<div className='text-xs text-muted-foreground font-mono'>
标识符: {source.name} · 类型: {source.type.toUpperCase()}
{t('identifier')}: {source.name} · {t('type')}:{' '}
{source.type.toUpperCase()}
</div>
</div>
<div className='flex items-center gap-4'>
@@ -455,8 +459,8 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
}`}
>
{source.client_secret_configured
? 'Secret 已配置'
: 'Secret 未配置'}
? t('secretConfigured')
: t('secretNotConfigured')}
</span>
<div className='flex items-center gap-2'>
@@ -489,7 +493,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
onClick={() => {
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
t('deleteAuthSourceConfirm', {
name: source.display_name || source.name,
}),
)
) {
deleteSourceMutation.mutate(source.id);
@@ -504,7 +510,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
))
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
<span>暂无配置的认证源,点击上方按钮新增</span>
<span>{t('noAuthSources')}</span>
<Button
type='button'
size='sm'
@@ -516,7 +522,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增认证源
{t('addAuthSource')}
</Button>
</div>
)}
@@ -532,11 +538,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
人机验证配置 (Cap CAPTCHA)
{t('captchaConfig')}
</CardTitle>
<CardDescription className='text-xs'>
配置基于 Proof-of-Work (PoW)
的无感人机验证,保护系统登录免受暴力破解和撞库攻击
{t('captchaConfigDesc')}
</CardDescription>
</div>
</div>
@@ -552,18 +557,18 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
{/* 自动开始计算 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'>打开页面后自动开始计算</p>
<p className='text-sm font-semibold'>{t('autoStartSolving')}</p>
</div>
<Switch checked={capAutoSolve} onCheckedChange={setCapAutoSolve} />
</div>
<form onSubmit={handleCapSave} className='space-y-6'>
<form onSubmit={handleCapSubmit} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label
htmlFor='cap_challenge_count'
className='text-xs font-semibold'
>
难题数量 (Count)
{t('challengeCount')}
</Label>
<Input
id='cap_challenge_count'
@@ -576,7 +581,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
客户端需求解的难题总数。默认 1,推荐 1 至 5
{t('challengeCountDesc')}
</p>
</div>
@@ -585,7 +590,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_difficulty'
className='text-xs font-semibold'
>
验证难度 (Difficulty)
{t('challengeDifficulty')}
</Label>
<Input
id='cap_challenge_difficulty'
@@ -598,7 +603,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4
{t('challengeDifficultyDesc')}
</p>
</div>
@@ -607,7 +612,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_size'
className='text-xs font-semibold'
>
盐值长度 (Size)
{t('challengeSize')}
</Label>
<Input
id='cap_challenge_size'
@@ -620,7 +625,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
难题盐值混淆字符长度。默认 32
{t('challengeSizeDesc')}
</p>
</div>
@@ -629,7 +634,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_ttl'
className='text-xs font-semibold'
>
难题超时时长 (秒)
{t('challengeTTL')}
</Label>
<Input
id='cap_challenge_ttl'
@@ -641,7 +646,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
难题有效期限。默认 600 秒 (10 分钟)
{t('challengeTTLDesc')}
</p>
</div>
@@ -650,7 +655,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_token_ttl'
className='text-xs font-semibold'
>
验证凭证有效时长 (秒)
{t('tokenTTL')}
</Label>
<Input
id='cap_token_ttl'
@@ -662,8 +667,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20
分钟)
{t('tokenTTLDesc')}
</p>
</div>
</div>
@@ -677,10 +681,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
{saveCapMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -31,6 +31,7 @@ import { Badge } from '@/components/ui/badge';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface SystemTabProps {
configs: Record<string, SystemConfig>;
@@ -39,6 +40,7 @@ interface SystemTabProps {
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.system');
const [serverAddress, setServerAddress] = useState('');
const [siteName, setSiteName] = useState('');
const [smtpHost, setSmtpHost] = useState('');
@@ -65,7 +67,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const handleDetectAddress = () => {
if (typeof window !== 'undefined') {
setServerAddress(window.location.origin);
toast.success('已自动获取当前域名并填充');
toast.success(t('addressAutoDetected'));
}
};
@@ -85,10 +87,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('配置已更新');
toast.success(t('configUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -114,14 +116,14 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
}),
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
]);
toast.success('通用配置已成功保存');
toast.success(t('generalConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSystemSave = (e: React.FormEvent) => {
const handleSystemSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSystemMutation.mutate();
};
@@ -156,21 +158,21 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('SMTP 邮件配置已成功保存');
toast.success(t('smtpConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSmtpSave = (e: React.FormEvent) => {
const handleSmtpSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSmtpMutation.mutate();
};
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog('正在发起连接测试...\n');
setSmtpTestLog(t('connectingTest'));
setSmtpTestSuccess(null);
setSmtpTestError('');
@@ -187,25 +189,27 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
setSmtpTestLog(data.log);
if (data.success) {
setSmtpTestSuccess(true);
toast.success('测试邮件发送成功');
toast.success(t('testEmailSent'));
} else {
setSmtpTestSuccess(false);
setSmtpTestError(data.error || '发送失败,请检查配置和日志。');
toast.error('测试邮件发送失败');
setSmtpTestError(data.error || t('sendFailedCheckConfig'));
toast.error(t('testEmailFailed'));
}
},
onError: (error: Error) => {
setSmtpTestSuccess(false);
setSmtpTestError(error.message || '请求发送失败');
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
toast.error(error.message || '测试请求发送失败');
setSmtpTestError(error.message || t('requestFailed'));
setSmtpTestLog(
(prev) => prev + `\n${t('requestError')} ${error.message}\n`,
);
toast.error(error.message || t('testRequestFailed'));
},
});
const handleSmtpTestSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!smtpTestTo) {
toast.error('请输入目标邮箱地址');
toast.error(t('enterTargetEmail'));
return;
}
testSmtpMutation.mutate();
@@ -225,20 +229,20 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
通用设置
{t('generalSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置站点标识、服务地址与搜索引擎收录策略
{t('generalSettingsDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSystemSave} className='flex flex-col gap-6'>
<form onSubmit={handleSystemSubmit} 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'>
站点名称
{t('siteName')}
</Label>
<Input
id='site_name'
@@ -249,7 +253,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
用于网页标题、登录注册页面、管理后台与系统邮件。
{t('siteNameDesc')}
</p>
</div>
@@ -259,7 +263,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='server_address'
className='text-xs font-semibold'
>
服务器访问地址
{t('serverAddress')}
</Label>
<Button
type='button'
@@ -269,7 +273,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='h-auto px-0 text-xs'
>
<Sparkles data-icon='inline-start' />
使用当前域名
{t('useCurrentDomain')}
</Button>
</div>
<Input
@@ -281,7 +285,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
限定 API 的允许来源;留空将开放任意来源访问。
{t('serverAddressDesc')}
</p>
</div>
</div>
@@ -294,14 +298,16 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='flex min-w-0 flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-xs font-semibold'>
搜索引擎抓取与收录
{t('searchEngineIndexing')}
</span>
<Badge variant={indexingEnabled ? 'default' : 'secondary'}>
{indexingEnabled ? '允许收录' : '禁止收录'}
{indexingEnabled
? t('indexingAllowed')
: t('indexingDenied')}
</Badge>
</div>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
{t('searchEngineIndexingDesc')}
</p>
</div>
</div>
@@ -324,7 +330,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2 text-[11px] text-muted-foreground'>
<Globe className='size-3.5' />
地址变更会影响跨域访问策略,请填写完整协议与域名。
{t('addressChangeWarning')}
</div>
<Button
type='submit'
@@ -337,10 +343,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
data-icon='inline-start'
className='animate-spin'
/>
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -357,20 +363,20 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
SMTP 邮件设置
{t('smtpSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统的邮件发送服务 (SMTP)
{t('smtpSettingsDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSmtpSave} className='space-y-6'>
<form onSubmit={handleSmtpSubmit} 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'>
SMTP 服务器地址
{t('smtpHost')}
</Label>
<Input
id='smtp_host'
@@ -384,7 +390,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='space-y-1.5'>
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
SMTP 端口
{t('smtpPort')}
</Label>
<Input
id='smtp_port'
@@ -401,7 +407,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='smtp_username'
className='text-xs font-semibold'
>
SMTP 账户
{t('smtpUsername')}
</Label>
<Input
id='smtp_username'
@@ -418,7 +424,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='smtp_password'
className='text-xs font-semibold'
>
SMTP 访问凭证
{t('smtpPassword')}
</Label>
<Input
id='smtp_password'
@@ -427,8 +433,8 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={
configs['smtp_password']?.value === '******'
? '•••••• (已配置,留空或输入新值)'
: '输入凭证密码'
? t('smtpPasswordMasked')
: t('smtpPasswordPlaceholder')
}
className='bg-card border-dashed text-xs'
/>
@@ -449,7 +455,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
}}
disabled={saveSmtpMutation.isPending}
>
测试发件
{t('testSend')}
</Button>
<Button
type='submit'
@@ -459,10 +465,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{saveSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -474,18 +480,17 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<DialogContent className='max-w-lg border border-dashed'>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
SMTP 发件测试
{t('smtpTestTitle')}
</DialogTitle>
<DialogDescription className='text-xs'>
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP
配置进行发件测试。
{t('smtpTestDesc')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSmtpTestSubmit} className='space-y-4'>
<div className='space-y-1.5'>
<Label htmlFor='smtp_test_to' className='text-xs font-semibold'>
目标邮箱地址
{t('targetEmail')}
</Label>
<Input
id='smtp_test_to'
@@ -501,7 +506,9 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{smtpTestLog && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>连接与传输日志</Label>
<Label className='text-xs font-semibold'>
{t('connectionLog')}
</Label>
<pre className='bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed'>
{smtpTestLog}
</pre>
@@ -510,13 +517,13 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{smtpTestSuccess === true && (
<div className='p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs'>
测试成功!邮件已顺利发出。
{t('testSuccess')}
</div>
)}
{smtpTestSuccess === false && (
<div className='p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all'>
测试失败:{smtpTestError}
{t('testFailed')}: {smtpTestError}
</div>
)}
@@ -528,7 +535,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
关闭
{t('close')}
</Button>
<Button
type='submit'
@@ -538,10 +545,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{testSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
测试中...
{t('testing')}
</>
) : (
'开始测试'
t('startTest')
)}
</Button>
</DialogFooter>
@@ -26,9 +26,11 @@ import {
import services from '@/lib/services';
import type { Template } from '@/lib/services/admin/types';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
export function TemplatesManager() {
const queryClient = useQueryClient();
const t = useTranslations('settings.templates');
const [modalOpen, setModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
@@ -60,11 +62,11 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功创建');
toast.success(t('templateCreated'));
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || '创建模板失败');
toast.error(error.message || t('createTemplateFailed'));
},
});
@@ -80,11 +82,11 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功保存');
toast.success(t('templateSaved'));
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || '修改模板失败');
toast.error(error.message || t('updateTemplateFailed'));
},
});
@@ -94,10 +96,10 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已删除');
toast.success(t('templateDeleted'));
},
onError: (error: Error) => {
toast.error(error.message || '删除模板失败');
toast.error(error.message || t('deleteTemplateFailed'));
},
});
@@ -145,10 +147,10 @@ export function TemplatesManager() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
通知模板管理
{t('templateManagement')}
</CardTitle>
<CardDescription className='text-xs'>
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
{t('templateManagementDesc')}
</CardDescription>
</div>
</div>
@@ -159,7 +161,7 @@ export function TemplatesManager() {
variant='secondary'
>
<Plus className='mr-1.5 size-3.5' />
新增模板
{t('addTemplate')}
</Button>
</CardHeader>
<CardContent className='pt-6 space-y-4'>
@@ -186,18 +188,18 @@ export function TemplatesManager() {
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}`}
>
{tmpl.is_system ? '系统内置' : '自定义'}
{tmpl.is_system ? t('systemBuiltIn') : t('custom')}
</span>
<span className='text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono'>
{tmpl.type.toUpperCase()}
</span>
</div>
<div className='text-xs text-muted-foreground'>
标识符:{' '}
{t('identifier')}:{' '}
<span className='font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded'>
{tmpl.key}
</span>
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
{tmpl.subject && ` · ${t('subject')}: ${tmpl.subject}`}
</div>
{tmpl.description && (
<p className='text-xs text-muted-foreground/80 leading-relaxed max-w-xl'>
@@ -225,7 +227,9 @@ export function TemplatesManager() {
}
onClick={() => {
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
window.confirm(
t('deleteTemplateConfirm', { name: tmpl.name }),
)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
@@ -239,7 +243,7 @@ export function TemplatesManager() {
</div>
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
<span>暂无配置的通知模板,点击上方按钮新增</span>
<span>{t('noTemplates')}</span>
<Button
type='button'
size='sm'
@@ -248,7 +252,7 @@ export function TemplatesManager() {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增模板
{t('addTemplate')}
</Button>
</div>
)}
@@ -259,11 +263,10 @@ export function TemplatesManager() {
<DialogContent className='max-w-xl border border-dashed'>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
{selectedTemplate ? t('editTemplate') : t('addTemplateTitle')}
</DialogTitle>
<DialogDescription className='text-xs'>
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `
{'{{.Code}}'}` 表示验证码变量)。
{t('templateDesc')}
</DialogDescription>
</DialogHeader>
@@ -271,7 +274,7 @@ export function TemplatesManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_key' className='text-xs font-semibold'>
模板标识符 (Key)
{t('templateKey')}
</Label>
<Input
id='tmpl_key'
@@ -284,13 +287,13 @@ export function TemplatesManager() {
disabled={!!selectedTemplate}
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
模板的唯一标识,在代码中通过此 Key 调用。
{t('templateKeyDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
模板名称
{t('templateName')}
</Label>
<Input
id='tmpl_name'
@@ -302,13 +305,13 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
用于后台识别该模板的可读名称。
{t('templateNameDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
模板类型
{t('templateType')}
</Label>
<Input
id='tmpl_type'
@@ -320,13 +323,13 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
模板分类标识,目前支持 `email`。
{t('templateTypeDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
模板主题 (Subject)
{t('templateSubject')}
</Label>
<Input
id='tmpl_subject'
@@ -337,7 +340,7 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
邮件标题(类型为 email 时生效)。
{t('templateSubjectDesc')}
</p>
</div>
</div>
@@ -347,7 +350,7 @@ export function TemplatesManager() {
htmlFor='tmpl_description'
className='text-xs font-semibold'
>
模板说明与变量描述
{t('templateDescription')}
</Label>
<Input
id='tmpl_description'
@@ -361,7 +364,7 @@ export function TemplatesManager() {
<div className='space-y-1.5'>
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
模板正文内容 (Content)
{t('templateContent')}
</Label>
<Textarea
id='tmpl_content'
@@ -382,16 +385,16 @@ export function TemplatesManager() {
onClick={() => setModalOpen(false)}
disabled={isPending}
>
取消
{t('cancel')}
</Button>
<Button type='submit' size='sm' disabled={isPending}>
{isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</DialogFooter>
@@ -20,6 +20,7 @@ import {
Upload,
} from 'lucide-react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -70,6 +71,7 @@ function formatDate(dateStr: string) {
export function UserFileManager() {
const queryClient = useQueryClient();
const t = useTranslations('files');
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
const [page, setPage] = React.useState(1);
@@ -119,11 +121,11 @@ export function UserFileManager() {
setUploading(true);
},
onSuccess: () => {
toast.success('文件上传成功');
toast.success(t('uploadSuccess'));
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
},
onError: (err: Error) => {
toast.error(err.message || '上传失败');
toast.error(err.message || t('uploadFailed'));
},
onSettled: () => {
setUploading(false);
@@ -137,12 +139,12 @@ export function UserFileManager() {
const deleteMutation = useMutation({
mutationFn: (id: string) => services.upload.deleteMyFile(id),
onSuccess: () => {
toast.success('文件已成功删除');
toast.success(t('deleteSuccess'));
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
setDeleteTarget(null);
},
onError: (err: Error) => {
toast.error(err.message || '删除失败');
toast.error(err.message || t('deleteFailed'));
},
});
@@ -172,7 +174,7 @@ export function UserFileManager() {
<div className='relative flex-1 max-w-md'>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground' />
<Input
placeholder='搜索文件名...'
placeholder={t('searchPlaceholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className='pl-9 h-9'
@@ -189,7 +191,7 @@ export function UserFileManager() {
onClick={() => setUploadAccessMode(0)}
>
<Lock className='mr-1 size-3 text-amber-500' />
私有
{t('private')}
</Button>
<Button
variant={uploadAccessMode === 1 ? 'secondary' : 'ghost'}
@@ -198,7 +200,7 @@ export function UserFileManager() {
onClick={() => setUploadAccessMode(1)}
>
<Globe className='mr-1 size-3 text-sky-500' />
公开
{t('public')}
</Button>
</div>
@@ -219,7 +221,7 @@ export function UserFileManager() {
) : (
<Plus className='size-3.5' />
)}
{uploading ? '正在上传...' : '上传文件'}
{uploading ? t('uploading') : t('uploadFile')}
</Button>
</div>
</div>
@@ -236,11 +238,9 @@ export function UserFileManager() {
<Upload className='size-10' />
</div>
<div className='space-y-1'>
<h3 className='font-semibold text-sm'>还没有上传文件</h3>
<h3 className='font-semibold text-sm'>{t('noFiles')}</h3>
<p className='text-xs text-muted-foreground max-w-xs'>
{debouncedKeyword
? '未搜索到匹配的文件'
: '上传您的第一个文件,支持图片、视频、文档和压缩包等格式。'}
{debouncedKeyword ? t('noMatchingFiles') : t('uploadFirstFile')}
</p>
</div>
{!debouncedKeyword && (
@@ -250,7 +250,7 @@ export function UserFileManager() {
size='sm'
className='mt-2 text-xs'
>
选择文件上传
{t('selectFileUpload')}
</Button>
)}
</CardContent>
@@ -284,7 +284,7 @@ export function UserFileManager() {
className='bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal'
>
<Lock className='size-2.5' />
私有
{t('private')}
</Badge>
) : (
<Badge
@@ -292,7 +292,7 @@ export function UserFileManager() {
className='bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal'
>
<Globe className='size-2.5' />
公开
{t('public')}
</Badge>
)}
</div>
@@ -328,7 +328,7 @@ export function UserFileManager() {
size='icon'
variant='ghost'
className='size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='下载文件'
title={t('downloadFile')}
onClick={() => handleDownload(file)}
>
<Download className='size-3.5' />
@@ -337,7 +337,7 @@ export function UserFileManager() {
size='icon'
variant='ghost'
className='size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
title='删除文件'
title={t('deleteFile')}
onClick={() => setDeleteTarget(file)}
>
<Trash2 className='size-3.5' />
@@ -361,7 +361,7 @@ export function UserFileManager() {
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className='mr-1 size-3.5' />
上一页
{t('prevPage')}
</Button>
<span className='text-xs text-muted-foreground font-medium px-2'>
{page} / {totalPages}
@@ -373,7 +373,7 @@ export function UserFileManager() {
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
{t('nextPage')}
<ChevronRight className='ml-1 size-3.5' />
</Button>
</div>
@@ -386,18 +386,16 @@ export function UserFileManager() {
>
<AlertDialogContent suppressHydrationWarning>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogTitle>{t('confirmDeleteFile')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?删除后此文件将无法恢复。
{t('confirmDeleteFileDesc', {
name: deleteTarget?.file_name || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
@@ -409,7 +407,7 @@ export function UserFileManager() {
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+13 -17
View File
@@ -4,20 +4,19 @@ 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 = `# 注册用户
const codeContent = `# ${t('codeComment')}
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
@@ -32,7 +31,6 @@ 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';
@@ -97,7 +95,9 @@ 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'># 快速开始</span>
<span className='text-green-400'>
# {t('codeQuickStart')}
</span>
{'\n'}
<span className='text-purple-400'>curl</span> -X{' '}
<span className='text-yellow-400'>POST</span>{' '}
@@ -130,22 +130,18 @@ 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'>
开发者友好,
<br />
极速集成上手
{t('heading')}
</h2>
<p className='text-muted-foreground text-lg leading-relaxed mb-8'>
标准化的 RESTful API 接口,完整的 TypeScript
类型定义,详细的文档和 Swagger
UI。无论使用什么编程语言,只需几行代码,即可快速集成。
{t('description')}
</p>
<ul className='space-y-4 mb-8'>
{[
'RESTful API,语义清晰,易于理解',
'OpenAPI / Swagger 接口文档',
'TypeScript 类型定义,开发友好',
'丰富的代码示例和集成指南',
t('features.restful'),
t('features.swagger'),
t('features.typescript'),
t('features.examples'),
].map((item, i) => (
<li
key={i}
@@ -166,7 +162,7 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
className='rounded-full text-xs hover:bg-muted-foreground/10'
>
<Book className='w-3 h-3' />
API 文档
{t('apiDocs')}
</Button>
</Link>
</div>
+37 -28
View File
@@ -2,17 +2,17 @@ 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';
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(
@@ -32,8 +32,7 @@ export const FooterSection = React.memo(function FooterSection({
</span>
</Link>
<p className='text-muted-foreground text-base leading-relaxed max-w-sm'>
通用的、现代化的后台管理系统脚手架。基于 Go + React
构建,开箱即用、完整基建、极易扩展。
{t('description')}
</p>
<div className='flex gap-4 pt-2'>
<SocialLink
@@ -44,36 +43,46 @@ export const FooterSection = React.memo(function FooterSection({
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>产品</h3>
<h3 className='font-semibold text-foreground mb-6'>
{t('product')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/home'>仪表盘</FooterLink>
<FooterLink href='/home'>{t('dashboard')}</FooterLink>
</li>
<li>
<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='/docs/how-to-use'>快速开始</FooterLink>
</li>
<li>
<FooterLink href='/docs/api'>API 文档</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet'>
源代码
<FooterLink href='/settings'>
{t('personalSettings')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>社区</h3>
<h3 className='font-semibold text-foreground mb-6'>
{t('development')}
</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'>
@@ -82,7 +91,7 @@ export const FooterSection = React.memo(function FooterSection({
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/discussions'>
讨论
{t('discussions')}
</FooterLink>
</li>
</ul>
@@ -96,13 +105,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>
+10 -10
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
@@ -57,9 +57,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
@@ -78,7 +78,7 @@ export const HeroSection = React.memo(function HeroSection({
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>
@@ -89,7 +89,7 @@ export const HeroSection = React.memo(function HeroSection({
size='lg'
className='w-full rounded-full font-medium active:scale-95'
>
了解更多
{t('learnMore')}
</Button>
</Link>
</motion.div>
@@ -103,15 +103,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>开箱即用</span>
<span>{t('features.readyToUse')}</span>
</div>
<div className='flex items-center gap-2'>
<Globe className='w-5 h-5 text-blue-500' />
<span>高度可扩展</span>
<span>{t('features.highlyExtensible')}</span>
</div>
<div className='flex items-center gap-2'>
<Shield className='w-5 h-5 text-green-500' />
<span>工业级基建</span>
<span>{t('features.industrialGrade')}</span>
</div>
</motion.div>
</div>
+15 -5
View File
@@ -3,6 +3,7 @@ import { FileTextIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 空状态展示组件属性
@@ -55,6 +56,9 @@ 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' };
@@ -76,10 +80,14 @@ export function EmptyState({
/>
</div>
{title && <h3 className='text-base font-medium mb-1'>{title}</h3>}
{displayTitle && (
<h3 className='text-base font-medium mb-1'>{displayTitle}</h3>
)}
{description && (
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
{displayDescription && (
<p className='text-sm text-muted-foreground max-w-md'>
{displayDescription}
</p>
)}
{onAction && actionText && (
@@ -128,10 +136,12 @@ 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(
@@ -140,7 +150,7 @@ export function EmptyInline({
)}
>
<Icon className='size-4' />
<span>{message}</span>
<span>{displayMessage}</span>
</div>
);
}
+13 -8
View File
@@ -3,6 +3,7 @@ import { AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 错误展示组件属性
@@ -48,16 +49,19 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = '加载失败',
title,
message,
error,
onRetry,
retryText = '重试',
retryText,
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
const t = useTranslations('layout.error');
const displayTitle = title ?? t('loadFailed');
const displayRetryText = retryText ?? t('retry');
const errorMessage = message || error?.message || t('unknownError');
return (
<div
@@ -70,7 +74,7 @@ export function ErrorDisplay({
<Icon className='size-6 text-red-600 dark:text-red-400' />
</div>
<h3 className='text-lg font-semibold mb-2'>{title}</h3>
<h3 className='text-lg font-semibold mb-2'>{displayTitle}</h3>
<p className='text-sm text-muted-foreground max-w-md mb-4'>
{errorMessage}
@@ -78,14 +82,14 @@ export function ErrorDisplay({
{onRetry && (
<Button onClick={onRetry} variant='outline' className='mt-2'>
{retryText}
{displayRetryText}
</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}
@@ -115,7 +119,8 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || '发生未知错误';
const t = useTranslations('layout.error');
const errorMessage = message || error?.message || t('unknownErrorShort');
return (
<div
@@ -133,7 +138,7 @@ export function ErrorInline({
size='sm'
className='h-6 px-4 text-xs'
>
重试
{t('retry')}
</Button>
)}
</div>
+12 -6
View File
@@ -2,6 +2,7 @@ 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';
/**
* 加载页面组件
@@ -88,12 +89,15 @@ 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' };
@@ -115,13 +119,15 @@ export function LoadingState({
/>
</div>
{title && (
<h3 className='text-sm font-medium mb-1 animate-pulse'>{title}</h3>
{displayTitle && (
<h3 className='text-sm font-medium mb-1 animate-pulse'>
{displayTitle}
</h3>
)}
{description && (
{displayDescription && (
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
{description}
{displayDescription}
</p>
)}
</div>
+44 -31
View File
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { type SearchItem, searchItems } from '@/lib/utils/search-data';
import { FileText, Home, Settings, Shield } from 'lucide-react';
import { useUser } from '@/contexts/user-context';
import { useTranslations } from 'next-intl';
interface SearchDialogProps {
open: boolean;
@@ -28,34 +29,43 @@ const categoryIcons = {
};
const categoryLabels = {
page: '页面',
feature: '功能',
setting: '设置',
admin: '管理',
page: 'categoryPage',
feature: 'categoryFeature',
setting: 'categorySetting',
admin: 'categoryAdmin',
};
const getTips = (metaKey: string) => [
const getTips = (metaKey: string, t: (key: string) => string) => [
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 还可以使用</span>
<span className='text-muted-foreground/80 lowercase'>
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>/</Kbd>
<span className='text-muted-foreground/80 lowercase'>来打开此界面</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 使用</span>
<Kbd className='mx-1'>↑</Kbd>
<Kbd className='mx-1'>↓</Kbd>
<span className='text-muted-foreground/80 lowercase'>来切换选中项</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 按住</span>
<Kbd className='mx-1'>{metaKey}</Kbd>
<span className='text-muted-foreground/80 lowercase'>+</span>
<Kbd className='mx-1'>↵</Kbd>
<span className='text-muted-foreground/80 lowercase'>在新标签页打开</span>
<span className='text-muted-foreground/80 lowercase'> {t('tipSlash')}</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
你知道吗:搜索功能还在持续升级中
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>↑</Kbd>
<Kbd className='mx-1'>↓</Kbd>
<span className='text-muted-foreground/80 lowercase'> {t('tipArrow')}</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>{metaKey}</Kbd>
<span className='text-muted-foreground/80 lowercase'>+</span>
<Kbd className='mx-1'>↵</Kbd>
<span className='text-muted-foreground/80 lowercase'>
{' '}
{t('tipNewTab')}
</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
{t('tipDidYouKnow')}
</span>
</>,
];
@@ -63,6 +73,7 @@ const getTips = (metaKey: string) => [
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter();
const { user } = useUser();
const t = useTranslations('layout.search');
const [search, setSearch] = useState('');
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
const [results, setResults] = useState<SearchItem[]>([]);
@@ -99,11 +110,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
useEffect(() => {
if (open) {
const tips = getTips(metaKey);
const tips = getTips(metaKey, t);
const randomTip = tips[Math.floor(Math.random() * tips.length)];
setCurrentTip(randomTip);
}
}, [open, metaKey]);
}, [open, metaKey, t]);
useEffect(() => {
const items = searchItems(search, user?.is_admin);
@@ -170,18 +181,20 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
>
<CommandInput
placeholder='搜索页面和功能...'
placeholder={t('searchPlaceholder')}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
<CommandEmpty>{t('noResults')}</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons];
return (
<CommandGroup
key={category}
heading={categoryLabels[category as keyof typeof categoryLabels]}
heading={t(
categoryLabels[category as keyof typeof categoryLabels],
)}
>
{items.map((item) => (
<CommandItem
@@ -217,11 +230,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
</div>
</div>
<span className='ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground'>
{
{t(
categoryLabels[
item.category as keyof typeof categoryLabels
]
}
],
)}
</span>
</CommandItem>
))}
@@ -235,11 +248,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
<Kbd>↵</Kbd>
</KbdGroup>
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
<span>{isCtrlPressed ? t('openInNewTab') : t('open')}</span>
</div>
<div className='flex items-center gap-1'>
<Kbd>Esc</Kbd>
<span>关闭搜索界面</span>
<span>{t('closeSearch')}</span>
</div>
<div className='ml-auto flex items-center gap-1'>{currentTip}</div>
</div>
@@ -4,9 +4,11 @@ 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(),
@@ -18,19 +20,19 @@ export function SiteTitleUpdater() {
// Determine the page suffix based on path
let suffix = '';
if (pathname === '/login') {
suffix = ' - 登录';
suffix = ` - ${t('login')}`;
} else if (pathname === '/register') {
suffix = ' - 注册';
suffix = ` - ${t('register')}`;
} else if (pathname.startsWith('/admin')) {
suffix = ' - 后台管理';
suffix = ` - ${t('admin')}`;
} else if (pathname === '/home') {
suffix = ' - 控制台';
suffix = ` - ${t('dashboard')}`;
} else if (pathname === '/') {
suffix = '';
}
document.title = `${siteName}${suffix}`;
}, [publicConfigQuery.data?.site_name, pathname]);
}, [publicConfigQuery.data?.site_name, pathname, t]);
return null;
}