mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 23:16:37 +08:00
prettier
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
import { type PolicySection } from './types';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
import {
|
||||
DocsTable,
|
||||
DocsTableBody,
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
DocsTableHead,
|
||||
DocsTableHeader,
|
||||
DocsTableRow,
|
||||
} from "@/components/ui/docs-table"
|
||||
} from '@/components/ui/docs-table';
|
||||
|
||||
export const DOCS_LAST_UPDATED = "2026-06-07"
|
||||
export const DOCS_LAST_UPDATED = '2026-06-07';
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
@@ -18,39 +18,53 @@ export const DOCS_LAST_UPDATED = "2026-06-07"
|
||||
*/
|
||||
export const apiSections: PolicySection[] = [
|
||||
{
|
||||
value: "api-specs",
|
||||
title: "1. 接口规范与鉴权说明",
|
||||
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 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>
|
||||
|
||||
<h3 id="1-1-response-format" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.1 统一响应格式</h3>
|
||||
<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 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 className='font-mono text-xs'>
|
||||
error_msg
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>错误信息。请求成功时为空字符串 `""`,失败时包含错误详情描述。</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
错误信息。请求成功时为空字符串
|
||||
`""`,失败时包含错误详情描述。
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">data</DocsTableCell>
|
||||
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
|
||||
<DocsTableCell>any</DocsTableCell>
|
||||
<DocsTableCell>接口返回的具体数据内容。请求失败或无数据返回时为 `null`。</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<p className="mt-2">成功响应示例:</p>
|
||||
<p className='mt-2'>成功响应示例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -60,70 +74,112 @@ export const apiSections: PolicySection[] = [
|
||||
"nickname": "Ryan"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
language='json'
|
||||
/>
|
||||
|
||||
<p className="mt-2">失败响应示例:</p>
|
||||
<p className='mt-2'>失败响应示例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "用户密码错误",
|
||||
"data": null
|
||||
}`}
|
||||
language="json"
|
||||
language='json'
|
||||
/>
|
||||
|
||||
<h3 id="1-2-authentication" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">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>
|
||||
<h3
|
||||
id='1-2-authentication'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
|
||||
>
|
||||
1.2 鉴权方式
|
||||
</h3>
|
||||
<p>
|
||||
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
|
||||
</p>
|
||||
<ul className='list-disc pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
|
||||
Cookie 会话保持登录。
|
||||
</li>
|
||||
<li>
|
||||
<strong>AccessToken 令牌:</strong>
|
||||
供后台调用或第三方应用集成使用。客户端生成 API
|
||||
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bg-muted border rounded-xl p-4 mt-2 space-y-2">
|
||||
<p className="font-bold text-xs">支持携带令牌的请求头格式(二选一):</p>
|
||||
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
|
||||
<li><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>
|
||||
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
|
||||
<p className='font-bold text-xs'>
|
||||
支持携带令牌的请求头格式(二选一):
|
||||
</p>
|
||||
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
|
||||
<li>
|
||||
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
|
||||
Authorization: Bearer at_xxx
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
|
||||
X-Access-Token: at_xxx
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "1-1-response-format", title: "1.1 统一响应格式" },
|
||||
{ value: "1-2-authentication", title: "1.2 鉴权方式" },
|
||||
]
|
||||
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
|
||||
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "auth-apis",
|
||||
title: "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>
|
||||
<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 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 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 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 className='font-mono text-xs'>
|
||||
nickname
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
|
||||
@@ -131,27 +187,44 @@ export const apiSections: PolicySection[] = [
|
||||
</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>
|
||||
<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 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 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 className='font-mono text-xs'>
|
||||
password
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>密码</DocsTableCell>
|
||||
@@ -159,41 +232,103 @@ export const apiSections: PolicySection[] = [
|
||||
</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'
|
||||
>
|
||||
2.3 退出登录
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
/api/v1/user/logout
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
|
||||
</p>
|
||||
|
||||
<h3 id="2-4-profile" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">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>
|
||||
<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: '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) 接口",
|
||||
value: 'token-apis',
|
||||
title: '3. 个人访问令牌 (AccessToken) 接口',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4">
|
||||
<p className="text-muted-foreground m-0">AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。</p>
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
|
||||
<p className='text-muted-foreground m-0'>
|
||||
AccessToken 管理相关接口均要求通过 Session
|
||||
登录后调用,支持普通用户权限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
>
|
||||
3.1 获取令牌列表
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
/api/v1/user/access-tokens
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>说明:</strong>
|
||||
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
|
||||
</p>
|
||||
|
||||
<h3 id="3-2-create-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">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>
|
||||
<h3
|
||||
id='3-2-create-token'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
|
||||
>
|
||||
3.2 新建访问令牌
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>POST{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
/api/v1/user/access-tokens
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>参数:</strong>JSON Body{' '}
|
||||
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
|
||||
Token,切勿遗失。
|
||||
</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
|
||||
<code className='bg-muted px-1 rounded'>false</code>
|
||||
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
|
||||
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
|
||||
</p>
|
||||
<p className='mt-2'>成功返回样例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -209,34 +344,74 @@ 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'
|
||||
>
|
||||
3.3 撤销/删除令牌
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>DELETE{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
/api/v1/user/access-tokens/:id
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>说明:</strong>通过 ID
|
||||
物理删除对应访问令牌,该令牌将立即失效。
|
||||
</p>
|
||||
|
||||
<h3 id="3-4-rotate-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">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>
|
||||
<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: '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. 公共配置与管理接口",
|
||||
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>
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<h3
|
||||
id='4-1-public-config'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
4.1 公共系统配置
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
/api/v1/config/public
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>说明:</strong>
|
||||
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
|
||||
</p>
|
||||
<p className='mt-2'>返回数据结构样例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -248,22 +423,50 @@ 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>
|
||||
<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 (管理员)" },
|
||||
]
|
||||
}
|
||||
]
|
||||
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
|
||||
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
import { type PolicySection } from './types';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
@@ -8,131 +8,247 @@ import {CodeBlock} from "@/components/ui/code-block"
|
||||
*/
|
||||
export const howToUseSections: PolicySection[] = [
|
||||
{
|
||||
value: "quick-start",
|
||||
title: "1. 快速开始",
|
||||
value: 'quick-start',
|
||||
title: '1. 快速开始',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明</p>
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
|
||||
<p className='text-muted-foreground m-0'>
|
||||
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
|
||||
</p>
|
||||
</div>
|
||||
<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 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. 身份认证与安全设置",
|
||||
value: 'auth-security',
|
||||
title: '2. 身份认证与安全设置',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<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>
|
||||
<h3
|
||||
id='2-1-login'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
2.1 常规账号密码认证
|
||||
</h3>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>自主注册与密码登录:</strong>
|
||||
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
bcrypt
|
||||
</code>{' '}
|
||||
高强度加盐哈希存储。
|
||||
</li>
|
||||
<li>
|
||||
<strong>系统开关控制:</strong>
|
||||
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="2-2-oidc" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.2 第三方 OIDC 认证源</h3>
|
||||
<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 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: '2-1-login', title: '2.1 常规账号密码认证' },
|
||||
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "access-token",
|
||||
title: "3. 个人访问令牌 (AccessToken) 接口对接",
|
||||
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>
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
为便于开发者或第三方工具直接调用系统
|
||||
API,平台提供个人访问令牌管理功能。
|
||||
</p>
|
||||
<h3
|
||||
id='3-1-generation'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
3.1 令牌生成与存储规范
|
||||
</h3>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
|
||||
(形如{' '}
|
||||
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
|
||||
at_xxx
|
||||
</code>
|
||||
)
|
||||
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
|
||||
</li>
|
||||
<li>
|
||||
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
|
||||
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="3-2-usage" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">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>
|
||||
<h3
|
||||
id='3-2-usage'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
3.2 携带 Header 进行 API 调用
|
||||
</h3>
|
||||
<p>
|
||||
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
|
||||
Http 请求头携带方式之一:
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-semibold text-xs text-muted-foreground'>
|
||||
方式一:Authorization Bearer 头
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
Host: localhost:8000
|
||||
Authorization: Bearer at_628d022b7a95e26b...`}
|
||||
language="http"
|
||||
language='http'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-xs text-muted-foreground">方式二:X-Access-Token 自定义头</p>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-semibold text-xs text-muted-foreground'>
|
||||
方式二:X-Access-Token 自定义头
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
Host: localhost:8000
|
||||
X-Access-Token: at_628d022b7a95e26b...`}
|
||||
language="http"
|
||||
language='http'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
|
||||
{ value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
|
||||
]
|
||||
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
|
||||
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "config-system",
|
||||
title: "4. 动态系统配置项",
|
||||
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>
|
||||
<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. 异步任务与定时调度",
|
||||
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>
|
||||
<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. 链路追踪与结构化日志",
|
||||
value: 'tracing-metrics',
|
||||
title: '6. 链路追踪与结构化日志',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
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 type {PolicySection} from "./types"
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
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 type { PolicySection } from './types';
|
||||
|
||||
interface LegalPageLayoutProps {
|
||||
title: string
|
||||
lastUpdated: string
|
||||
sections: PolicySection[]
|
||||
description?: React.ReactNode
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
sections: PolicySection[];
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LegalPageLayout({
|
||||
@@ -21,245 +21,288 @@ export function LegalPageLayout({
|
||||
sections,
|
||||
description,
|
||||
}: LegalPageLayoutProps) {
|
||||
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const { scrollYProgress } = useScroll()
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [activeSection, setActiveSection] = React.useState<string>(
|
||||
sections[0]?.value || '',
|
||||
);
|
||||
const [hasCopied, setHasCopied] = React.useState(false);
|
||||
const { scrollYProgress } = useScroll();
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
})
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
const allSectionIds = React.useMemo(() => {
|
||||
return sections.flatMap(section => [
|
||||
return sections.flatMap((section) => [
|
||||
section.value,
|
||||
...(section.children?.map(child => child.value) || [])
|
||||
])
|
||||
}, [sections])
|
||||
...(section.children?.map((child) => child.value) || []),
|
||||
]);
|
||||
}, [sections]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id)
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-20% 0px -35% 0px" }
|
||||
)
|
||||
{ rootMargin: '-20% 0px -35% 0px' },
|
||||
);
|
||||
|
||||
allSectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) observer.observe(element)
|
||||
})
|
||||
const element = document.getElementById(id);
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [sections, allSectionIds])
|
||||
return () => observer.disconnect();
|
||||
}, [sections, allSectionIds]);
|
||||
|
||||
const convertInlineNodes = (nodes: ChildNode[]): string => {
|
||||
return nodes.map((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ""
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ""
|
||||
}
|
||||
const element = node as HTMLElement
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
if (tagName === "code") {
|
||||
return `\`${element.textContent?.trim() || ""}\``
|
||||
}
|
||||
if (tagName === "strong" || tagName === "b") {
|
||||
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
|
||||
}
|
||||
if (tagName === "em" || tagName === "i") {
|
||||
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
|
||||
}
|
||||
if (tagName === "a") {
|
||||
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
|
||||
const href = element.getAttribute("href") || ""
|
||||
return href ? `[${text}](${href})` : text
|
||||
}
|
||||
if (tagName === "br") {
|
||||
return "\n"
|
||||
}
|
||||
return convertInlineNodes(Array.from(element.childNodes))
|
||||
}).join("")
|
||||
}
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || '';
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
const element = node as HTMLElement;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === 'code') {
|
||||
return `\`${element.textContent?.trim() || ''}\``;
|
||||
}
|
||||
if (tagName === 'strong' || tagName === 'b') {
|
||||
return `**${convertInlineNodes(Array.from(element.childNodes))}**`;
|
||||
}
|
||||
if (tagName === 'em' || tagName === 'i') {
|
||||
return `*${convertInlineNodes(Array.from(element.childNodes))}*`;
|
||||
}
|
||||
if (tagName === 'a') {
|
||||
const text =
|
||||
convertInlineNodes(Array.from(element.childNodes)) ||
|
||||
element.textContent ||
|
||||
'';
|
||||
const href = element.getAttribute('href') || '';
|
||||
return href ? `[${text}](${href})` : text;
|
||||
}
|
||||
if (tagName === 'br') {
|
||||
return '\n';
|
||||
}
|
||||
return convertInlineNodes(Array.from(element.childNodes));
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
const convertList = (
|
||||
list: HTMLOListElement | HTMLUListElement,
|
||||
indentLevel: number
|
||||
indentLevel: number,
|
||||
): string => {
|
||||
const isOrdered = list.tagName.toLowerCase() === "ol"
|
||||
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
|
||||
return items.map((item, index) => {
|
||||
const prefix = isOrdered ? `${index + 1}. ` : "- "
|
||||
const indent = " ".repeat(indentLevel)
|
||||
const parts: string[] = []
|
||||
const itemElement = item as HTMLElement
|
||||
const childNodes = Array.from(itemElement.childNodes)
|
||||
const nestedLists: HTMLElement[] = []
|
||||
const isOrdered = list.tagName.toLowerCase() === 'ol';
|
||||
const items = Array.from(list.children).filter(
|
||||
(child) => child.tagName.toLowerCase() === 'li',
|
||||
);
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const prefix = isOrdered ? `${index + 1}. ` : '- ';
|
||||
const indent = ' '.repeat(indentLevel);
|
||||
const parts: string[] = [];
|
||||
const itemElement = item as HTMLElement;
|
||||
const childNodes = Array.from(itemElement.childNodes);
|
||||
const nestedLists: HTMLElement[] = [];
|
||||
|
||||
childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childElement = child as HTMLElement
|
||||
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
|
||||
nestedLists.push(childElement)
|
||||
return
|
||||
childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childElement = child as HTMLElement;
|
||||
if (
|
||||
childElement.tagName.toLowerCase() === 'ul' ||
|
||||
childElement.tagName.toLowerCase() === 'ol'
|
||||
) {
|
||||
nestedLists.push(childElement);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.push(convertInlineNodes([child]).trim())
|
||||
})
|
||||
parts.push(convertInlineNodes([child]).trim());
|
||||
});
|
||||
|
||||
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
|
||||
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
|
||||
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
|
||||
}).join("\n")
|
||||
}
|
||||
const baseLine = `${indent}${prefix}${parts.join('').trim()}`;
|
||||
const nestedMarkdown = nestedLists.map((nested) =>
|
||||
convertList(
|
||||
nested as HTMLOListElement | HTMLUListElement,
|
||||
indentLevel + 1,
|
||||
),
|
||||
);
|
||||
return [baseLine, ...nestedMarkdown].filter(Boolean).join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const convertTable = (table: HTMLTableElement): string => {
|
||||
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
|
||||
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
|
||||
)
|
||||
if (!rows.length) return ""
|
||||
const header = rows[0]
|
||||
const body = rows.slice(1)
|
||||
const headerLine = `| ${header.join(" | ")} |`
|
||||
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
|
||||
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
|
||||
return [headerLine, separatorLine, ...bodyLines].join("\n")
|
||||
}
|
||||
const rows = Array.from(table.querySelectorAll('tr')).map((row) =>
|
||||
Array.from(row.querySelectorAll('th,td')).map((cell) =>
|
||||
(cell.textContent || '').trim(),
|
||||
),
|
||||
);
|
||||
if (!rows.length) return '';
|
||||
const header = rows[0];
|
||||
const body = rows.slice(1);
|
||||
const headerLine = `| ${header.join(' | ')} |`;
|
||||
const separatorLine = `| ${header.map(() => '---').join(' | ')} |`;
|
||||
const bodyLines = body.map((row) => `| ${row.join(' | ')} |`);
|
||||
return [headerLine, separatorLine, ...bodyLines].join('\n');
|
||||
};
|
||||
|
||||
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
|
||||
const tagName = node.tagName.toLowerCase()
|
||||
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
|
||||
if (tagName === "pre") {
|
||||
const code = node.textContent || ""
|
||||
return `\`\`\`\n${code.trim()}\n\`\`\``
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === 'h3') return `### ${node.textContent?.trim() || ''}`;
|
||||
if (tagName === 'h4') return `#### ${node.textContent?.trim() || ''}`;
|
||||
if (tagName === 'p')
|
||||
return convertInlineNodes(Array.from(node.childNodes)).trim();
|
||||
if (tagName === 'ul' || tagName === 'ol')
|
||||
return convertList(
|
||||
node as HTMLOListElement | HTMLUListElement,
|
||||
indentLevel,
|
||||
);
|
||||
if (tagName === 'pre') {
|
||||
const code = node.textContent || '';
|
||||
return `\`\`\`\n${code.trim()}\n\`\`\``;
|
||||
}
|
||||
if (tagName === "table") return convertTable(node as HTMLTableElement)
|
||||
if (tagName === "div") {
|
||||
const hasTable = node.querySelector("table")
|
||||
const hasList = node.querySelector("ul,ol")
|
||||
const hasPre = node.querySelector("pre")
|
||||
if (tagName === 'table') return convertTable(node as HTMLTableElement);
|
||||
if (tagName === 'div') {
|
||||
const hasTable = node.querySelector('table');
|
||||
const hasList = node.querySelector('ul,ol');
|
||||
const hasPre = node.querySelector('pre');
|
||||
if (hasTable || hasList || hasPre) {
|
||||
return Array.from(node.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.join('\n\n');
|
||||
}
|
||||
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
return text
|
||||
const text = convertInlineNodes(Array.from(node.childNodes)).trim();
|
||||
return text;
|
||||
}
|
||||
return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
}
|
||||
return convertInlineNodes(Array.from(node.childNodes)).trim();
|
||||
};
|
||||
|
||||
const buildMarkdown = (): string => {
|
||||
const contentRoot = contentRef.current
|
||||
if (!contentRoot) return ""
|
||||
const lines: string[] = []
|
||||
lines.push(`# ${title}`)
|
||||
lines.push("")
|
||||
lines.push(`最后更新:${lastUpdated}`)
|
||||
const contentRoot = contentRef.current;
|
||||
if (!contentRoot) return '';
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title}`);
|
||||
lines.push('');
|
||||
lines.push(`最后更新:${lastUpdated}`);
|
||||
if (description) {
|
||||
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
|
||||
const descriptionElement = contentRoot.querySelector(
|
||||
'[data-docs-description]',
|
||||
);
|
||||
if (descriptionElement) {
|
||||
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
|
||||
const descriptionText = convertInlineNodes(
|
||||
Array.from(descriptionElement.childNodes),
|
||||
).trim();
|
||||
if (descriptionText) {
|
||||
lines.push("")
|
||||
lines.push(descriptionText)
|
||||
lines.push('');
|
||||
lines.push(descriptionText);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
lines.push('');
|
||||
|
||||
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
|
||||
const sectionNodes = Array.from(
|
||||
contentRoot.querySelectorAll('[data-docs-section]'),
|
||||
) as HTMLElement[];
|
||||
sectionNodes.forEach((section) => {
|
||||
const heading = section.querySelector("h2")
|
||||
const heading = section.querySelector('h2');
|
||||
if (heading?.textContent) {
|
||||
lines.push(`## ${heading.textContent.trim()}`)
|
||||
lines.push("")
|
||||
lines.push(`## ${heading.textContent.trim()}`);
|
||||
lines.push('');
|
||||
}
|
||||
const prose = section.querySelector("[data-docs-content]")
|
||||
const prose = section.querySelector('[data-docs-content]');
|
||||
if (prose) {
|
||||
const blocks = Array.from(prose.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, 0))
|
||||
.filter(Boolean)
|
||||
.filter(Boolean);
|
||||
if (blocks.length) {
|
||||
lines.push(blocks.join("\n\n"))
|
||||
lines.push("")
|
||||
lines.push(blocks.join('\n\n'));
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
|
||||
}
|
||||
return (
|
||||
lines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim() + '\n'
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
const markdown = buildMarkdown()
|
||||
if (!markdown) return
|
||||
const markdown = buildMarkdown();
|
||||
if (!markdown) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = markdown
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.opacity = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = markdown;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
const element = document.getElementById(id)
|
||||
const scrollToSection = (
|
||||
e: React.MouseEvent<HTMLAnchorElement>,
|
||||
id: string,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
const offset = 80
|
||||
const bodyRect = document.body.getBoundingClientRect().top
|
||||
const elementRect = element.getBoundingClientRect().top
|
||||
const elementPosition = elementRect - bodyRect
|
||||
const offsetPosition = elementPosition - offset
|
||||
const offset = 80;
|
||||
const bodyRect = document.body.getBoundingClientRect().top;
|
||||
const elementRect = element.getBoundingClientRect().top;
|
||||
const elementPosition = elementRect - bodyRect;
|
||||
const offsetPosition = elementPosition - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth"
|
||||
})
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative selection:bg-primary/20">
|
||||
<div className='min-h-screen bg-background relative selection:bg-primary/20'>
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
|
||||
className='fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50'
|
||||
style={{ scaleX }}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
|
||||
<div className='fixed inset-0 pointer-events-none'>
|
||||
<div className='absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2' />
|
||||
<div className='absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2' />
|
||||
</div>
|
||||
|
||||
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Link href="/" className="inline-block mb-8">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className='container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16'
|
||||
>
|
||||
<header className='mb-8 md:mb-12'>
|
||||
<Link href='/' className='inline-block mb-8'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
|
||||
variant='ghost'
|
||||
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className='w-4 h-4' />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -269,32 +312,39 @@ export function LegalPageLayout({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
|
||||
<h1 className='text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6'>
|
||||
{title}
|
||||
</h1>
|
||||
<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" />
|
||||
<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} 个内容
|
||||
</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" />
|
||||
<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}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCopyMarkdown}
|
||||
className="h-8 gap-2 text-xs"
|
||||
className='h-8 gap-2 text-xs'
|
||||
>
|
||||
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{hasCopied ? "已复制" : "复制 Markdown"}
|
||||
{hasCopied ? (
|
||||
<Check className='w-3.5 h-3.5' />
|
||||
) : (
|
||||
<Copy className='w-3.5 h-3.5' />
|
||||
)}
|
||||
{hasCopied ? '已复制' : '复制 Markdown'}
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
|
||||
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
|
||||
<div className='w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden'>
|
||||
<div
|
||||
data-docs-description
|
||||
className='text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all'
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,44 +352,46 @@ export function LegalPageLayout({
|
||||
</motion.div>
|
||||
</header>
|
||||
|
||||
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
|
||||
<aside className="hidden lg:block lg:col-span-3">
|
||||
<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">目录</div>
|
||||
<nav className="space-y-1">
|
||||
<div className='grid lg:grid-cols-12 gap-8 lg:gap-12 relative'>
|
||||
<aside className='hidden lg:block lg:col-span-3'>
|
||||
<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'>
|
||||
目录
|
||||
</div>
|
||||
<nav className='space-y-1'>
|
||||
{sections.map((section) => (
|
||||
<div key={section.value} className="space-y-1">
|
||||
<div key={section.value} className='space-y-1'>
|
||||
<a
|
||||
href={`#${ section.value }`}
|
||||
href={`#${section.value}`}
|
||||
onClick={(e) => scrollToSection(e, section.value)}
|
||||
className={cn(
|
||||
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
|
||||
'group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200',
|
||||
activeSection === section.value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{section.title}</span>
|
||||
<span className='truncate'>{section.title}</span>
|
||||
{activeSection === section.value && (
|
||||
<motion.div
|
||||
layoutId="active-dot"
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary"
|
||||
layoutId='active-dot'
|
||||
className='w-1.5 h-1.5 rounded-full bg-primary'
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
{section.children && (
|
||||
<div className="pl-4 space-y-1">
|
||||
<div className='pl-4 space-y-1'>
|
||||
{section.children.map((child) => (
|
||||
<a
|
||||
key={child.value}
|
||||
href={`#${ child.value }`}
|
||||
href={`#${child.value}`}
|
||||
onClick={(e) => scrollToSection(e, child.value)}
|
||||
className={cn(
|
||||
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
|
||||
'block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate',
|
||||
activeSection === child.value
|
||||
? "text-primary font-medium bg-primary/5"
|
||||
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
|
||||
? 'text-primary font-medium bg-primary/5'
|
||||
: 'text-muted-foreground/80 hover:text-foreground hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
@@ -354,23 +406,23 @@ export function LegalPageLayout({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
|
||||
<main className='lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20'>
|
||||
{sections.map((section, index) => (
|
||||
<motion.section
|
||||
key={section.value}
|
||||
id={section.value}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "100px" }}
|
||||
viewport={{ once: true, margin: '100px' }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="scroll-mt-24 group"
|
||||
className='scroll-mt-24 group'
|
||||
data-docs-section
|
||||
>
|
||||
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<div className='sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0'>
|
||||
<span className='flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0'>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
<h2 className='text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300'>
|
||||
{section.title.replace(/^\d+\.\s*/, '')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -378,25 +430,29 @@ export function LegalPageLayout({
|
||||
<div
|
||||
data-docs-content
|
||||
className={cn(
|
||||
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
|
||||
"prose-p:text-muted-foreground prose-p:leading-7",
|
||||
"prose-li:text-muted-foreground",
|
||||
"prose-strong:text-foreground",
|
||||
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
|
||||
)}>
|
||||
'prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none',
|
||||
'prose-p:text-muted-foreground prose-p:leading-7',
|
||||
'prose-li:text-muted-foreground',
|
||||
'prose-strong:text-foreground',
|
||||
'pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500',
|
||||
)}
|
||||
>
|
||||
{section.content}
|
||||
</div>
|
||||
</motion.section>
|
||||
))}
|
||||
|
||||
<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">
|
||||
<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'>
|
||||
对这些内容有疑问?
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link href="https://github.com/Rain-kl/Wavelet/issues" target="_blank">
|
||||
<Button variant="outline" className="h-9 text-xs">
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Link
|
||||
href='https://github.com/Rain-kl/Wavelet/issues'
|
||||
target='_blank'
|
||||
>
|
||||
<Button variant='outline' className='h-9 text-xs'>
|
||||
联系支持
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -405,7 +461,7 @@ export function LegalPageLayout({
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import { type PolicySection } from './types';
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
@@ -7,94 +7,154 @@ import {type PolicySection} from "./types"
|
||||
*/
|
||||
export const privacySections: PolicySection[] = [
|
||||
{
|
||||
value: "collection-details",
|
||||
title: "1. 详细信息收集说明",
|
||||
value: 'collection-details',
|
||||
title: '1. 详细信息收集说明',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<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>
|
||||
<span className='font-semibold text-foreground'>
|
||||
1.2 服务与接口日志信息:
|
||||
</span>
|
||||
<p className='mt-1 text-muted-foreground'>
|
||||
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
|
||||
IP 地址、访问日期和时间、个人访问令牌 API
|
||||
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "storage-security",
|
||||
title: "2. 数据存储与安全保护",
|
||||
value: 'storage-security',
|
||||
title: '2. 数据存储与安全保护',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<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 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. 信息使用规范",
|
||||
value: 'usage-rules',
|
||||
title: '3. 信息使用规范',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<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 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>
|
||||
<p>
|
||||
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
|
||||
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "sharing-disclosure",
|
||||
title: "4. 信息共享与对外披露",
|
||||
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">
|
||||
<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>
|
||||
<li>
|
||||
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
|
||||
</li>
|
||||
</ul>
|
||||
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。</p>
|
||||
<p>
|
||||
<strong>4.2 转让与公开披露:</strong>
|
||||
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "user-rights",
|
||||
title: "5. 您的权利与数据管理",
|
||||
value: 'user-rights',
|
||||
title: '5. 您的权利与数据管理',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<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>
|
||||
<span className='font-semibold text-foreground'>
|
||||
5.2 删除与注销权:
|
||||
</span>
|
||||
<p className='mt-1 text-muted-foreground'>
|
||||
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "policy-update",
|
||||
title: "6. 政策更新与通知",
|
||||
value: 'policy-update',
|
||||
title: '6. 政策更新与通知',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
|
||||
<p>当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。</p>
|
||||
<p>
|
||||
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import { type PolicySection } from './types';
|
||||
|
||||
export const TERMS_LAST_UPDATED = "2026-06-07"
|
||||
export const TERMS_LAST_UPDATED = '2026-06-07';
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
@@ -9,68 +9,128 @@ export const TERMS_LAST_UPDATED = "2026-06-07"
|
||||
*/
|
||||
export const termsSections: PolicySection[] = [
|
||||
{
|
||||
value: "contract-establishment",
|
||||
title: "1. 缔约申明与服务综述",
|
||||
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 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. 服务定义与性质界定",
|
||||
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>
|
||||
<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. 账号注册与使用规范",
|
||||
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>
|
||||
<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. 用户行为准则(负面清单)",
|
||||
value: 'user-conduct',
|
||||
title: '4. 用户行为准则(负面清单)',
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
|
||||
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
|
||||
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
|
||||
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
|
||||
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
|
||||
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
|
||||
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
|
||||
</p>
|
||||
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
|
||||
<li>
|
||||
<strong>危害国家安全:</strong>
|
||||
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
|
||||
</li>
|
||||
<li>
|
||||
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
|
||||
攻击服务、服务器爆破等其他非法信息服务平台;
|
||||
</li>
|
||||
<li>
|
||||
<strong>黄赌毒关联:</strong>
|
||||
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
|
||||
</li>
|
||||
<li>
|
||||
<strong>侵犯知识产权:</strong>
|
||||
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
|
||||
</li>
|
||||
<li>
|
||||
<strong>其他违法信息:</strong>
|
||||
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><strong>处理规则:</strong>一旦发现您违反上述规定,平台运维主体有权不经通知<strong>立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。</strong></p>
|
||||
<p>
|
||||
<strong>处理规则:</strong>
|
||||
一旦发现您违反上述规定,平台运维主体有权不经通知
|
||||
<strong>
|
||||
立即永久封禁您的账号、拦截所有 AccessToken
|
||||
请求,并依法向公安机关、网安部门移交相关线索。
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "liability-limitation",
|
||||
title: "5. 免责声明与不可抗力",
|
||||
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">
|
||||
<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>
|
||||
@@ -80,13 +140,20 @@ export const termsSections: PolicySection[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "governing-law",
|
||||
title: "6. 法律适用与争议解决",
|
||||
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 className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>6.1 法律适用:</strong>
|
||||
本协议的订立、执行、解释及争议的解决均适用
|
||||
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
|
||||
</p>
|
||||
<p>
|
||||
<strong>6.2 争议解决:</strong>
|
||||
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export type PolicySection = {
|
||||
value: string
|
||||
title: string
|
||||
content: ReactNode
|
||||
value: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
children?: {
|
||||
value: string
|
||||
title: string
|
||||
}[]
|
||||
}
|
||||
value: string;
|
||||
title: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {ImageOff} from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {getFileUrl, type ImageQuality} from "@/lib/services/upload/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getFileUrl, type ImageQuality } from '@/lib/services/upload/utils';
|
||||
|
||||
type FileImagePreviewProps = {
|
||||
fileId: string | number
|
||||
alt: string
|
||||
quality?: ImageQuality
|
||||
className?: string
|
||||
fallbackClassName?: string
|
||||
variant?: "compact" | "default"
|
||||
}
|
||||
fileId: string | number;
|
||||
alt: string;
|
||||
quality?: ImageQuality;
|
||||
className?: string;
|
||||
fallbackClassName?: string;
|
||||
variant?: 'compact' | 'default';
|
||||
};
|
||||
|
||||
export function FileImagePreview({
|
||||
fileId,
|
||||
alt,
|
||||
quality = "low",
|
||||
quality = 'low',
|
||||
className,
|
||||
fallbackClassName,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
}: FileImagePreviewProps) {
|
||||
const [failed, setFailed] = React.useState(false)
|
||||
const src = getFileUrl(fileId, quality)
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const src = getFileUrl(fileId, quality);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFailed(false)
|
||||
}, [fileId, quality])
|
||||
setFailed(false);
|
||||
}, [fileId, quality]);
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
role='img'
|
||||
aria-label={`${alt} 加载失败`}
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground",
|
||||
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
<ImageOff className={cn("shrink-0 opacity-70", variant === "compact" ? "size-3.5" : "size-5")} />
|
||||
{variant === "default" && (
|
||||
<span className="text-[10px] leading-none">加载失败</span>
|
||||
<ImageOff
|
||||
className={cn(
|
||||
'shrink-0 opacity-70',
|
||||
variant === 'compact' ? 'size-3.5' : 'size-5',
|
||||
)}
|
||||
/>
|
||||
{variant === 'default' && (
|
||||
<span className='text-[10px] leading-none'>加载失败</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -53,10 +58,10 @@ export function FileImagePreview({
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
loading='lazy'
|
||||
decoding='async'
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react"
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Layers, ListRestart, LucideIcon, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,49 +15,54 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
|
||||
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
|
||||
interface ManagePageProps<T> {
|
||||
title: string
|
||||
icon?: LucideIcon
|
||||
data: T[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
onReload: () => void
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
data: T[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
onReload: () => void;
|
||||
|
||||
/** 获取初始编辑数据 */
|
||||
getInitialEditData: (item: T) => Partial<T>
|
||||
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
||||
onDelete?: (item: T) => Promise<void>
|
||||
getInitialEditData: (item: T) => Partial<T>;
|
||||
onSave: (item: T, editData: Partial<T>) => Promise<void>;
|
||||
onDelete?: (item: T) => Promise<void>;
|
||||
|
||||
/** 渲染表格 (Config-Driven) */
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
header: string;
|
||||
cell: (item: T) => React.ReactNode;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
className?: string;
|
||||
}[];
|
||||
|
||||
/** 渲染详情 */
|
||||
renderDetail: (props: {
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
editData: Partial<T>
|
||||
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) => React.ReactNode
|
||||
selected: T | null;
|
||||
hovered: T | null;
|
||||
editData: Partial<T>;
|
||||
onEditDataChange: (field: keyof T, value: T[keyof T]) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}) => React.ReactNode;
|
||||
|
||||
/** 空状态图标 */
|
||||
emptyIcon?: LucideIcon
|
||||
emptyDescription?: string
|
||||
loadingDescription?: string
|
||||
getId: (item: T) => string | number
|
||||
headerExtra?: React.ReactNode
|
||||
emptyIcon?: LucideIcon;
|
||||
emptyDescription?: string;
|
||||
loadingDescription?: string;
|
||||
getId: (item: T) => string | number;
|
||||
headerExtra?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ManagePage<T>({
|
||||
@@ -73,85 +78,85 @@ export function ManagePage<T>({
|
||||
columns,
|
||||
renderDetail,
|
||||
emptyIcon = Layers,
|
||||
emptyDescription = "暂无数据",
|
||||
loadingDescription = "加载中",
|
||||
emptyDescription = '暂无数据',
|
||||
loadingDescription = '加载中',
|
||||
getId,
|
||||
headerExtra
|
||||
headerExtra,
|
||||
}: ManagePageProps<T>) {
|
||||
/** 悬停状态 */
|
||||
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null)
|
||||
const [editData, setEditData] = useState<Partial<T>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingItem, setDeletingItem] = useState<T | null>(null)
|
||||
const [hoveredItem, setHoveredItem] = useState<T | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<T>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingItem, setDeletingItem] = useState<T | null>(null);
|
||||
|
||||
/** 悬停处理 */
|
||||
const handleHover = (item: T | null) => {
|
||||
setHoveredItem(item)
|
||||
}
|
||||
setHoveredItem(item);
|
||||
};
|
||||
|
||||
/** 选择处理 */
|
||||
const handleSelect = (item: T) => {
|
||||
const itemId = getId(item)
|
||||
const selectedId = selectedItem ? getId(selectedItem) : null
|
||||
const itemId = getId(item);
|
||||
const selectedId = selectedItem ? getId(selectedItem) : null;
|
||||
|
||||
if (itemId === selectedId) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
setSelectedItem(null);
|
||||
setEditData({});
|
||||
} else {
|
||||
setSelectedItem(item)
|
||||
setEditData(getInitialEditData(item))
|
||||
setSelectedItem(item);
|
||||
setEditData(getInitialEditData(item));
|
||||
}
|
||||
setHoveredItem(null)
|
||||
}
|
||||
setHoveredItem(null);
|
||||
};
|
||||
|
||||
/** 编辑数据处理 */
|
||||
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
|
||||
setEditData(prev => ({
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
/** 保存处理 */
|
||||
const handleSave = async () => {
|
||||
if (!selectedItem) return
|
||||
if (!selectedItem) return;
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await onSave(selectedItem, editData)
|
||||
toast.success('保存成功')
|
||||
setSaving(true);
|
||||
await onSave(selectedItem, editData);
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除处理 */
|
||||
const handleDeleteClick = (item: T) => {
|
||||
setDeletingItem(item)
|
||||
}
|
||||
setDeletingItem(item);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingItem || !onDelete) return
|
||||
if (!deletingItem || !onDelete) return;
|
||||
try {
|
||||
await onDelete(deletingItem)
|
||||
toast.success('删除成功')
|
||||
setDeletingItem(null)
|
||||
await onDelete(deletingItem);
|
||||
toast.success('删除成功');
|
||||
setDeletingItem(null);
|
||||
// 如果删除的是当前选中的项,清除选中状态
|
||||
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
setSelectedItem(null);
|
||||
setEditData({});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
description: error instanceof Error ? error.message : '未知错误',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 渲染内容 */
|
||||
const renderContent = () => {
|
||||
@@ -161,28 +166,25 @@ export function ManagePage<T>({
|
||||
icon={ListRestart}
|
||||
description={loadingDescription}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
error={error}
|
||||
onRetry={onReload}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyStateWithBorder
|
||||
icon={emptyIcon}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
)
|
||||
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -196,27 +198,25 @@ export function ManagePage<T>({
|
||||
onDelete={onDelete ? handleDeleteClick : undefined}
|
||||
getId={getId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex pb-2 mb-6 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-5 text-primary" />}
|
||||
<div className='py-6'>
|
||||
<div className='flex pb-2 mb-6 items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{Icon && <Icon className='size-5 text-primary' />}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold">配置列表</div>
|
||||
<div className='mb-4'>
|
||||
<div className='font-semibold'>配置列表</div>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
@@ -228,12 +228,15 @@ export function ManagePage<T>({
|
||||
editData,
|
||||
onEditDataChange: handleEditDataChange,
|
||||
onSave: handleSave,
|
||||
saving
|
||||
saving,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
||||
<AlertDialog
|
||||
open={!!deletingItem}
|
||||
onOpenChange={(open) => !open && setDeletingItem(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除?</AlertDialogTitle>
|
||||
@@ -243,67 +246,73 @@ export function ManagePage<T>({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className='bg-red-600 hover:bg-red-700'
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 详情面板 props */
|
||||
interface ManageDetailPanelProps {
|
||||
title?: string
|
||||
isEmpty: boolean
|
||||
emptyDescription?: string
|
||||
onSave?: () => void
|
||||
saving?: boolean
|
||||
children: React.ReactNode
|
||||
title?: string;
|
||||
isEmpty: boolean;
|
||||
emptyDescription?: string;
|
||||
onSave?: () => void;
|
||||
saving?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 详情面板 */
|
||||
export function ManageDetailPanel({
|
||||
title = "配置信息",
|
||||
title = '配置信息',
|
||||
isEmpty,
|
||||
emptyDescription = "请选择配置查看详情",
|
||||
emptyDescription = '请选择配置查看详情',
|
||||
onSave,
|
||||
saving = false,
|
||||
children
|
||||
children,
|
||||
}: ManageDetailPanelProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold mb-4">{title}</div>
|
||||
<EmptyStateWithBorder
|
||||
icon={Layers}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
<div className='space-y-4'>
|
||||
<div className='font-semibold mb-4'>{title}</div>
|
||||
<EmptyStateWithBorder icon={Layers} description={emptyDescription} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sticky top-6">
|
||||
<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='flex items-center justify-between mb-4'>
|
||||
<div className='font-semibold'>{title}</div>
|
||||
{onSave && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
className="px-3 h-7 text-xs"
|
||||
size='sm'
|
||||
className='px-3 h-7 text-xs'
|
||||
>
|
||||
{saving ? (<><Spinner /> 更新中</>) : '更新'}
|
||||
{saving ? (
|
||||
<>
|
||||
<Spinner /> 更新中
|
||||
</>
|
||||
) : (
|
||||
'更新'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格面板 */
|
||||
@@ -315,56 +324,61 @@ export function ManageTable<T>({
|
||||
onSelect,
|
||||
onHover,
|
||||
onDelete,
|
||||
getId
|
||||
getId,
|
||||
}: {
|
||||
data: T[]
|
||||
data: T[];
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
onSelect: (item: T) => void
|
||||
onHover: (item: T | null) => void
|
||||
onDelete?: (item: T) => void
|
||||
getId: (item: T) => string | number
|
||||
header: string;
|
||||
cell: (item: T) => React.ReactNode;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
className?: string;
|
||||
}[];
|
||||
selected: T | null;
|
||||
hovered: T | null;
|
||||
onSelect: (item: T) => void;
|
||||
onHover: (item: T | null) => void;
|
||||
onDelete?: (item: T) => void;
|
||||
getId: (item: T) => string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="relative w-full">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
|
||||
<ScrollArea className='w-full'>
|
||||
<div className='relative w-full'>
|
||||
<table className='w-full caption-bottom text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-dashed">
|
||||
<TableRow className='border-b border-dashed'>
|
||||
{columns.map((col, index) => (
|
||||
<TableHead
|
||||
key={index}
|
||||
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
className={`whitespace-nowrap ${col.width ? `w-[${col.width}]` : ''} ${col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : ''} ${col.className || ''}`}
|
||||
>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
{onDelete && <TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>}
|
||||
{onDelete && (
|
||||
<TableHead className='whitespace-nowrap text-center w-[120px]'>
|
||||
操作
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="animate-in fade-in duration-200">
|
||||
<TableBody className='animate-in fade-in duration-200'>
|
||||
{data.map((item) => {
|
||||
const id = getId(item)
|
||||
const isSelected = selected && getId(selected) === id
|
||||
const isHovered = hovered && getId(hovered) === id
|
||||
const id = getId(item);
|
||||
const isSelected = selected && getId(selected) === id;
|
||||
const isHovered = hovered && getId(hovered) === id;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
|
||||
? 'bg-primary/5 hover:bg-primary/10'
|
||||
: isHovered
|
||||
? 'bg-gray-100 hover:bg-muted/50'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
className={`border-b border-dashed cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/5 hover:bg-primary/10'
|
||||
: isHovered
|
||||
? 'bg-gray-100 hover:bg-muted/50'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
onMouseEnter={() => onHover(item)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onSelect(item)}
|
||||
@@ -372,34 +386,34 @@ export function ManageTable<T>({
|
||||
{columns.map((col, index) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
className={`text-xs py-1 ${col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : ''} ${col.className || ''}`}
|
||||
>
|
||||
{col.cell(item)}
|
||||
</TableCell>
|
||||
))}
|
||||
{onDelete && (
|
||||
<TableCell className="text-xs py-1 text-center">
|
||||
<TableCell className='text-xs py-1 text-center'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(item)
|
||||
e.stopPropagation();
|
||||
onDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollBar orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface PasswordDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (password: string) => void
|
||||
loading?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (password: string) => void;
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码输入对话框
|
||||
*
|
||||
*
|
||||
* 用于敏感操作前的二次确认
|
||||
*/
|
||||
export function PasswordDialog({
|
||||
@@ -36,35 +36,33 @@ export function PasswordDialog({
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
title = "请输入密码",
|
||||
description = "为了您的账户安全,请输入6位认证密码进行验证"
|
||||
title = '请输入密码',
|
||||
description = '为了您的账户安全,请输入6位认证密码进行验证',
|
||||
}: PasswordDialogProps) {
|
||||
const [password, setPassword] = useState("")
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (password.length === 6) {
|
||||
onConfirm(password)
|
||||
onConfirm(password);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* 当对话框关闭时重置密码*/
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPassword("")
|
||||
setPassword('');
|
||||
}
|
||||
}, [isOpen])
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-xs sm:w-md">
|
||||
<DialogContent className='w-xs sm:w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 flex justify-center">
|
||||
<div className='py-2 flex justify-center'>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={password}
|
||||
@@ -72,47 +70,47 @@ export function PasswordDialog({
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && password.length === 6 && !loading) {
|
||||
handleConfirm()
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
|
||||
<InputOTPGroup className='gap-2'>
|
||||
<InputOTPSlot index={0} className='w-10 h-10 text-base' />
|
||||
<InputOTPSlot index={1} className='w-10 h-10 text-base' />
|
||||
<InputOTPSlot index={2} className='w-10 h-10 text-base' />
|
||||
<InputOTPSlot index={3} className='w-10 h-10 text-base' />
|
||||
<InputOTPSlot index={4} className='w-10 h-10 text-base' />
|
||||
<InputOTPSlot index={5} className='w-10 h-10 text-base' />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant='ghost'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-primary hover:bg-primary/90 h-8 text-xs"
|
||||
type='button'
|
||||
className='bg-primary hover:bg-primary/90 h-8 text-xs'
|
||||
onClick={handleConfirm}
|
||||
disabled={password.length !== 6 || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Spinner className='mr-2 h-4 w-4' />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
"确认"
|
||||
'确认'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +1,140 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {Card, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCheck, Terminal, User} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useUser } from '@/contexts/user-context';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Layers,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function HomeMain() {
|
||||
const { user } = useUser()
|
||||
const [isBannerVisible, setIsBannerVisible] = React.useState(true)
|
||||
const { user } = useUser();
|
||||
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "个人资料",
|
||||
description: "管理您的个人账户信息及个性化配置",
|
||||
title: '个人资料',
|
||||
description: '管理您的个人账户信息及个性化配置',
|
||||
icon: User,
|
||||
url: "/settings/profile",
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
borderColor: "hover:border-blue-500/30",
|
||||
url: '/settings/profile',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
borderColor: 'hover:border-blue-500/30',
|
||||
},
|
||||
{
|
||||
title: "开发接口文档",
|
||||
description: "查看开放平台的 RESTful 接口规格说明",
|
||||
title: '开发接口文档',
|
||||
description: '查看开放平台的 RESTful 接口规格说明',
|
||||
icon: FileText,
|
||||
url: "/docs/api",
|
||||
color: "text-emerald-500",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
borderColor: "hover:border-emerald-500/30",
|
||||
url: '/docs/api',
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'hover:border-emerald-500/30',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "使用文档",
|
||||
description: "学习如何集成 API 及日常操作帮助指南",
|
||||
title: '使用文档',
|
||||
description: '学习如何集成 API 及日常操作帮助指南',
|
||||
icon: HelpCircle,
|
||||
url: "/docs/how-to-use",
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
borderColor: "hover:border-purple-500/30",
|
||||
url: '/docs/how-to-use',
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
borderColor: 'hover:border-purple-500/30',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const adminLinks = [
|
||||
{
|
||||
title: "系统设置",
|
||||
description: "管理登录注册开关与认证源安全策略",
|
||||
title: '系统设置',
|
||||
description: '管理登录注册开关与认证源安全策略',
|
||||
icon: Shield,
|
||||
url: "/admin/settings",
|
||||
color: "text-amber-500",
|
||||
bgColor: "bg-amber-500/10",
|
||||
borderColor: "hover:border-amber-500/30",
|
||||
url: '/admin/settings',
|
||||
color: 'text-amber-500',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'hover:border-amber-500/30',
|
||||
},
|
||||
{
|
||||
title: "全局系统配置",
|
||||
description: "动态管理平台运行时核心配置参数",
|
||||
title: '全局系统配置',
|
||||
description: '动态管理平台运行时核心配置参数',
|
||||
icon: ShieldCheck,
|
||||
url: "/admin/system",
|
||||
color: "text-indigo-500",
|
||||
bgColor: "bg-indigo-500/10",
|
||||
borderColor: "hover:border-indigo-500/30",
|
||||
url: '/admin/system',
|
||||
color: 'text-indigo-500',
|
||||
bgColor: 'bg-indigo-500/10',
|
||||
borderColor: 'hover:border-indigo-500/30',
|
||||
},
|
||||
{
|
||||
title: "用户权限管理",
|
||||
description: "集中查询并管理系统注册用户的启用状态",
|
||||
title: '用户权限管理',
|
||||
description: '集中查询并管理系统注册用户的启用状态',
|
||||
icon: User,
|
||||
url: "/admin/users",
|
||||
color: "text-rose-500",
|
||||
bgColor: "bg-rose-500/10",
|
||||
borderColor: "hover:border-rose-500/30",
|
||||
url: '/admin/users',
|
||||
color: 'text-rose-500',
|
||||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'hover:border-rose-500/30',
|
||||
},
|
||||
{
|
||||
title: "后台任务调度",
|
||||
description: "分发与观测系统异步定时任务的执行情况",
|
||||
title: '后台任务调度',
|
||||
description: '分发与观测系统异步定时任务的执行情况',
|
||||
icon: Layers,
|
||||
url: "/admin/tasks",
|
||||
color: "text-teal-500",
|
||||
bgColor: "bg-teal-500/10",
|
||||
borderColor: "hover:border-teal-500/30",
|
||||
url: '/admin/tasks',
|
||||
color: 'text-teal-500',
|
||||
bgColor: 'bg-teal-500/10',
|
||||
borderColor: 'hover:border-teal-500/30',
|
||||
},
|
||||
{
|
||||
title: "系统日志",
|
||||
description: "查看异步任务执行日志与系统运行状态详情",
|
||||
title: '系统日志',
|
||||
description: '查看异步任务执行日志与系统运行状态详情',
|
||||
icon: Terminal,
|
||||
url: "/admin/logs",
|
||||
color: "text-cyan-500",
|
||||
bgColor: "bg-cyan-500/10",
|
||||
borderColor: "hover:border-cyan-500/30",
|
||||
url: '/admin/logs',
|
||||
color: 'text-cyan-500',
|
||||
bgColor: 'bg-cyan-500/10',
|
||||
borderColor: 'hover:border-cyan-500/30',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-8 max-w-6xl mx-auto">
|
||||
<div className='py-6 space-y-8 max-w-6xl mx-auto'>
|
||||
{/* 积分收益提示 banner */}
|
||||
<AnimatePresence>
|
||||
{isBannerVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative">
|
||||
<div className="max-w-xl space-y-3">
|
||||
<h3 className="text-lg font-bold text-indigo-600 dark:text-indigo-400">Modern Platform</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
通用的、现代化的后台管理系统脚手架, 开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
|
||||
<div className='bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative'>
|
||||
<div className='max-w-xl space-y-3'>
|
||||
<h3 className='text-lg font-bold text-indigo-600 dark:text-indigo-400'>
|
||||
Modern Platform
|
||||
</h3>
|
||||
<p className='text-xs text-muted-foreground leading-relaxed'>
|
||||
通用的、现代化的后台管理系统脚手架,
|
||||
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
|
||||
</p>
|
||||
<div className="flex gap-2.5">
|
||||
<div className='flex gap-2.5'>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7"
|
||||
size='sm'
|
||||
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'>开始使用</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7"
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
|
||||
onClick={() => setIsBannerVisible(false)}
|
||||
>
|
||||
隐藏提示
|
||||
@@ -134,9 +147,9 @@ export function HomeMain() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 快捷导航 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">常用功能导航</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className='space-y-4'>
|
||||
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
{quickLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
@@ -144,32 +157,50 @@ export function HomeMain() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: idx * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
<Card
|
||||
className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}
|
||||
>
|
||||
<CardHeader className='p-5'>
|
||||
<div
|
||||
className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}
|
||||
>
|
||||
<link.icon className='size-5' />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1 flex items-center gap-1">
|
||||
<CardTitle className='text-sm font-semibold mb-1 flex items-center gap-1'>
|
||||
{link.title}
|
||||
{link.external && <ExternalLink className="size-3 text-muted-foreground" />}
|
||||
{link.external && (
|
||||
<ExternalLink className='size-3 text-muted-foreground' />
|
||||
)}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
<p className='text-xs text-muted-foreground leading-normal font-normal'>
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<div className='px-5 pb-5'>
|
||||
{link.external ? (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
立即跳转 <ArrowRight className="size-3 ml-1" />
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0 h-auto text-xs text-indigo-500 font-medium'
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={link.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
立即跳转 <ArrowRight className='size-3 ml-1' />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0 h-auto text-xs text-indigo-500 font-medium'
|
||||
asChild
|
||||
>
|
||||
<Link href={link.url}>
|
||||
立即进入 <ArrowRight className="size-3 ml-1" />
|
||||
立即进入 <ArrowRight className='size-3 ml-1' />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -182,12 +213,12 @@ export function HomeMain() {
|
||||
|
||||
{/* 管理面板 (仅管理员可见) */}
|
||||
{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" />
|
||||
<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' />
|
||||
后台管理控制台
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
|
||||
{adminLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
@@ -195,24 +226,32 @@ export function HomeMain() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: (idx + 4) * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
<Card
|
||||
className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}
|
||||
>
|
||||
<CardHeader className='p-5'>
|
||||
<div
|
||||
className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}
|
||||
>
|
||||
<link.icon className='size-5' />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1">
|
||||
<CardTitle className='text-sm font-semibold mb-1'>
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
<p className='text-xs text-muted-foreground leading-normal font-normal'>
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<div className='px-5 pb-5'>
|
||||
<Button
|
||||
variant='link'
|
||||
className='p-0 h-auto text-xs text-indigo-500 font-medium'
|
||||
asChild
|
||||
>
|
||||
<Link href={link.url}>
|
||||
立即进入 <ArrowRight className="size-3 ml-1" />
|
||||
立即进入 <ArrowRight className='size-3 ml-1' />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -223,5 +262,5 @@ export function HomeMain() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {motion} from "motion/react"
|
||||
import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Shield, Trash2} from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,7 +36,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -27,240 +44,283 @@ import {
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {UserService} from "@/lib/services/user"
|
||||
import type {CreateTokenResponse} from "@/lib/services/user"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {toast} from "sonner"
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import type { CreateTokenResponse } from '@/lib/services/user';
|
||||
import { UserService } from '@/lib/services/user';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function AccessTokenMain() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||||
const [tokenName, setTokenName] = React.useState("")
|
||||
const [tokenIsAdmin, setTokenIsAdmin] = React.useState(false)
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null)
|
||||
const [newCreatedToken, setNewCreatedToken] = React.useState<CreateTokenResponse | null>(null)
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false);
|
||||
const [tokenName, setTokenName] = React.useState('');
|
||||
const [tokenIsAdmin, setTokenIsAdmin] = React.useState(false);
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null);
|
||||
const [newCreatedToken, setNewCreatedToken] =
|
||||
React.useState<CreateTokenResponse | null>(null);
|
||||
|
||||
// 获取 Token 列表
|
||||
const accessTokensQuery = useQuery({
|
||||
queryKey: ["user", "access-tokens"],
|
||||
queryKey: ['user', 'access-tokens'],
|
||||
queryFn: () => UserService.getAccessTokens(),
|
||||
})
|
||||
});
|
||||
|
||||
// 创建 Token
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
|
||||
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) =>
|
||||
UserService.createAccessToken(name, isAdmin),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setTokenName("")
|
||||
setTokenIsAdmin(false)
|
||||
setCreateDialogOpen(false)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌创建成功")
|
||||
setNewCreatedToken(data);
|
||||
setTokenName('');
|
||||
setTokenIsAdmin(false);
|
||||
setCreateDialogOpen(false);
|
||||
setViewDialogOpen(true);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['user', 'access-tokens'],
|
||||
});
|
||||
toast.success('访问令牌创建成功');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建访问令牌失败")
|
||||
toast.error(error.message || '创建访问令牌失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 删除 Token
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.deleteAccessToken(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌已撤销")
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['user', 'access-tokens'],
|
||||
});
|
||||
toast.success('访问令牌已撤销');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除访问令牌失败")
|
||||
toast.error(error.message || '删除访问令牌失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// 轮换 Token
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.rotateAccessToken(id),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌轮换成功,旧密钥已失效")
|
||||
setNewCreatedToken(data);
|
||||
setViewDialogOpen(true);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['user', 'access-tokens'],
|
||||
});
|
||||
toast.success('访问令牌轮换成功,旧密钥已失效');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "轮换访问令牌失败")
|
||||
toast.error(error.message || '轮换访问令牌失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (!tokenName.trim()) {
|
||||
toast.error("请输入令牌名称")
|
||||
return
|
||||
toast.error('请输入令牌名称');
|
||||
return;
|
||||
}
|
||||
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
|
||||
}
|
||||
createTokenMutation.mutate({
|
||||
name: tokenName.trim(),
|
||||
isAdmin: tokenIsAdmin,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
|
||||
deleteTokenMutation.mutate(id)
|
||||
if (
|
||||
window.confirm(
|
||||
`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`,
|
||||
)
|
||||
) {
|
||||
deleteTokenMutation.mutate(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
|
||||
rotateTokenMutation.mutate(id)
|
||||
if (
|
||||
window.confirm(
|
||||
`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`,
|
||||
)
|
||||
) {
|
||||
rotateTokenMutation.mutate(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = async (text: string, id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
toast.success("复制成功")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
toast.success('复制成功');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
toast.error('复制失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "未使用"
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
if (!dateStr) return '未使用';
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 max-w-4xl mx-auto px-4"
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
className='py-6 space-y-6 max-w-4xl mx-auto px-4'
|
||||
>
|
||||
<div className="font-semibold">
|
||||
<div className='font-semibold'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
访问令牌
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className='flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">个人访问令牌 (AccessToken)</h1>
|
||||
<p className="text-sm text-muted-foreground">管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API</p>
|
||||
<h1 className='text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent'>
|
||||
个人访问令牌 (AccessToken)
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
type='button'
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
variant={'secondary'}
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
<Plus className='mr-1.5 size-4' />
|
||||
生成新令牌
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 安全警告提示 */}
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed">
|
||||
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">安全提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
<div className='rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed'>
|
||||
<AlertTriangle className='size-4 shrink-0 mt-0.5' />
|
||||
<div className='space-y-1'>
|
||||
<span className='font-bold'>安全提示:</span>
|
||||
<p className='text-muted-foreground'>
|
||||
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<CardTitle className="text-base font-semibold">活动令牌列表</CardTitle>
|
||||
<CardDescription className="text-xs">当前可用的所有访问令牌</CardDescription>
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
活动令牌列表
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
当前可用的所有访问令牌
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<CardContent className='pt-6 space-y-4'>
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='size-6 animate-spin text-primary' />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{(accessTokensQuery.data ?? []).map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
className='flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm'
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{token.name}</span>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-semibold text-sm text-foreground'>
|
||||
{token.name}
|
||||
</span>
|
||||
{token.is_admin ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold">
|
||||
<Shield className="size-2.5 mr-0.5" />
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold'
|
||||
>
|
||||
<Shield className='size-2.5 mr-0.5' />
|
||||
管理员
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold">
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold'
|
||||
>
|
||||
用户令牌
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all">
|
||||
<div className='flex flex-col gap-1 text-xs text-muted-foreground'>
|
||||
<div className='font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all'>
|
||||
{token.masked_token}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pt-1">
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-0.5 pt-1'>
|
||||
<span>创建于: {formatDate(token.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 sm:self-center">
|
||||
<div className='flex items-center gap-2 shrink-0 sm:self-center'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5'
|
||||
onClick={() =>
|
||||
handleCopyText(token.masked_token, token.id)
|
||||
}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
<Check className="size-3.5 mr-1 text-emerald-500" />
|
||||
<Check className='size-3.5 mr-1 text-emerald-500' />
|
||||
) : (
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
)}
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5'
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
轮换
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5"
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5'
|
||||
onClick={() => handleDeleteToken(token.id, token.name)}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
@@ -268,17 +328,17 @@ export function AccessTokenMain() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<Key className="size-8 text-muted-foreground/30" />
|
||||
<div className='rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
|
||||
<Key className='size-8 text-muted-foreground/30' />
|
||||
<span>您当前暂无生成任何访问令牌</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='border-dashed'
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
生成第一个令牌
|
||||
</Button>
|
||||
</div>
|
||||
@@ -288,39 +348,46 @@ export function AccessTokenMain() {
|
||||
|
||||
{/* 创建令牌 Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<form onSubmit={handleCreateToken}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
生成新令牌
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs text-muted-foreground'>
|
||||
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name" className="text-xs font-semibold">令牌名称</Label>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='token-name' className='text-xs font-semibold'>
|
||||
令牌名称
|
||||
</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="例如:my-development-key"
|
||||
id='token-name'
|
||||
placeholder='例如:my-development-key'
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-primary focus:ring-0 focus-visible:ring-0"
|
||||
className='rounded-xl border border-dashed focus:border-primary focus:ring-0 focus-visible:ring-0'
|
||||
/>
|
||||
</div>
|
||||
{user?.is_admin && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 bg-muted/5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="token-admin" className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="size-3.5 text-rose-500" />
|
||||
<div className='flex items-center justify-between rounded-xl border border-dashed p-3 bg-muted/5'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label
|
||||
htmlFor='token-admin'
|
||||
className='text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<Shield className='size-3.5 text-rose-500' />
|
||||
管理员权限
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
<p className='text-[11px] text-muted-foreground leading-normal'>
|
||||
开启后此令牌可访问 /admin/** 管理端点,默认关闭
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="token-admin"
|
||||
id='token-admin'
|
||||
checked={tokenIsAdmin}
|
||||
onCheckedChange={setTokenIsAdmin}
|
||||
disabled={createTokenMutation.isPending}
|
||||
@@ -328,28 +395,28 @@ export function AccessTokenMain() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogFooter className='gap-2'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl text-xs h-9 border-dashed"
|
||||
className='rounded-xl text-xs h-9 border-dashed'
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
type='submit'
|
||||
disabled={createTokenMutation.isPending}
|
||||
variant={'secondary'}
|
||||
>
|
||||
{createTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
正在生成...
|
||||
</>
|
||||
) : (
|
||||
"生成令牌"
|
||||
'生成令牌'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -358,48 +425,51 @@ export function AccessTokenMain() {
|
||||
</Dialog>
|
||||
|
||||
{/* 明文 Token 显示 Dialog (仅显示一次) */}
|
||||
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<Dialog
|
||||
open={viewDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewCreatedToken(null);
|
||||
setViewDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-1.5">
|
||||
<Check className="size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5" />
|
||||
<DialogTitle className='text-base font-bold text-foreground flex items-center gap-1.5'>
|
||||
<Check className='size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5' />
|
||||
令牌密钥已就绪
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className='space-y-4 py-2'>
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed">
|
||||
<div className='flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3'>
|
||||
<span className='font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed'>
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors"
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors'
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
<Check className='size-3.5 text-emerald-500' />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
<Copy className='size-3.5' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 强提示 */}
|
||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">重要提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
<div className='rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<div className='space-y-1'>
|
||||
<span className='font-bold'>重要提示:</span>
|
||||
<p className='text-muted-foreground'>
|
||||
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
|
||||
</p>
|
||||
</div>
|
||||
@@ -409,12 +479,12 @@ export function AccessTokenMain() {
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
setNewCreatedToken(null);
|
||||
setViewDialogOpen(false);
|
||||
}}
|
||||
className="rounded-xl w-full"
|
||||
className='rounded-xl w-full'
|
||||
>
|
||||
我已经复制并妥善保存
|
||||
</Button>
|
||||
@@ -422,5 +492,5 @@ export function AccessTokenMain() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,251 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { Moon, Sun, Loader2, Check, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCustomTheme } from "@/lib/theme"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Check, Loader2, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCustomTheme } from '@/lib/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
function ThemeModeSection() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const modes = [
|
||||
{ id: "light", name: "明亮", icon: Sun },
|
||||
{ id: "dark", name: "黑暗", icon: Moon },
|
||||
{ id: "system", name: "自动", icon: Monitor }
|
||||
]
|
||||
{ id: 'light', name: '明亮', icon: Sun },
|
||||
{ id: 'dark', name: '黑暗', icon: Moon },
|
||||
{ id: 'system', name: '自动', icon: Monitor },
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
const Icon = mode.icon;
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='flex-1 text-xs'
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{Icon && <Icon className='size-3 mr-1' />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
const isActive = theme === mode.id
|
||||
const Icon = mode.icon;
|
||||
const isActive = theme === mode.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant={isActive ? "default" : "secondary"}
|
||||
size="sm"
|
||||
variant={isActive ? 'default' : 'secondary'}
|
||||
size='sm'
|
||||
onClick={() => setTheme(mode.id)}
|
||||
className="flex-1 text-xs"
|
||||
className='flex-1 text-xs'
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{Icon && <Icon className='size-3 mr-1' />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InterfaceAppearanceSection() {
|
||||
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
|
||||
const { theme: mode } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [switching, setSwitching] = React.useState<string | null>(null)
|
||||
const {
|
||||
themes,
|
||||
currentThemeId,
|
||||
setTheme: setCustomTheme,
|
||||
isLoading,
|
||||
} = useCustomTheme();
|
||||
const { theme: mode } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [switching, setSwitching] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleSetTheme = (themeId: string) => {
|
||||
if (switching === themeId) return
|
||||
setSwitching(themeId)
|
||||
if (switching === themeId) return;
|
||||
setSwitching(themeId);
|
||||
try {
|
||||
setCustomTheme(themeId)
|
||||
const themeName = themes.find(t => t.id === themeId)?.name || themeId
|
||||
toast.success(`外观已切换为 ${ themeName }`)
|
||||
setCustomTheme(themeId);
|
||||
const themeName = themes.find((t) => t.id === themeId)?.name || themeId;
|
||||
toast.success(`外观已切换为 ${themeName}`);
|
||||
} catch {
|
||||
toast.error("切换失败")
|
||||
toast.error('切换失败');
|
||||
} finally {
|
||||
setSwitching(null)
|
||||
setSwitching(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
const isDark =
|
||||
mounted &&
|
||||
(mode === 'dark' ||
|
||||
(mode === 'system' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h2 className='font-medium text-sm text-foreground'>界面外观</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择界面的配色方案和视觉风格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
|
||||
<div className='flex items-center justify-center p-12'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground/50' />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
<div className='grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6'>
|
||||
{themes.map((theme) => {
|
||||
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
|
||||
const isSwitching = switching === theme.id
|
||||
const colors = isDark ? theme.colors.dark : theme.colors.light
|
||||
const isActive =
|
||||
currentThemeId === theme.id ||
|
||||
(!currentThemeId && theme.id === 'default.css');
|
||||
const isSwitching = switching === theme.id;
|
||||
const colors = isDark ? theme.colors.dark : theme.colors.light;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={theme.id}
|
||||
onClick={() => handleSetTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
|
||||
'group relative cursor-pointer rounded-xl border transition-all overflow-hidden',
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border hover:border-border/50 hover:shadow-md"
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border hover:border-border/50 hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="aspect-[4/3] w-full relative"
|
||||
style={{ backgroundColor: colors.background || colors['background'] }}
|
||||
className='aspect-[4/3] w-full relative'
|
||||
style={{
|
||||
backgroundColor:
|
||||
colors.background || colors['background'],
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full p-2 gap-2">
|
||||
<div className='flex h-full p-2 gap-2'>
|
||||
<div
|
||||
className="w-1/4 h-full rounded-sm opacity-80"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
className='w-1/4 h-full rounded-sm opacity-80'
|
||||
style={{
|
||||
backgroundColor: colors.sidebar || colors['sidebar'],
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5 pt-1">
|
||||
<div className='flex-1 space-y-1.5 pt-1'>
|
||||
<div
|
||||
className="h-1.5 w-1/3 rounded-full opacity-90"
|
||||
style={{ backgroundColor: colors.primary || colors['primary'] }}
|
||||
className='h-1.5 w-1/3 rounded-full opacity-90'
|
||||
style={{
|
||||
backgroundColor:
|
||||
colors.primary || colors['primary'],
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-8 w-full rounded-sm opacity-40"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
className='h-8 w-full rounded-sm opacity-40'
|
||||
style={{
|
||||
backgroundColor:
|
||||
colors.sidebar || colors['sidebar'],
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-4 w-1/2 rounded-sm opacity-30"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
className='h-4 w-1/2 rounded-sm opacity-30'
|
||||
style={{
|
||||
backgroundColor:
|
||||
colors.sidebar || colors['sidebar'],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && !isSwitching && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
|
||||
<Check className="h-3 w-3" />
|
||||
<div className='absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm'>
|
||||
<Check className='h-3 w-3' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/50 bg-card">
|
||||
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
<div className='p-2 border-t border-border/50 bg-card'>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[10px] font-medium text-center truncate transition-colors',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{theme.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceMain() {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<div className='py-6 space-y-6'>
|
||||
<div className='font-semibold'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
外观设置
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
@@ -213,12 +255,14 @@ export function AppearanceMain() {
|
||||
|
||||
<InterfaceAppearanceSection />
|
||||
|
||||
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
|
||||
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
|
||||
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
|
||||
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
|
||||
<div className='space-y-4 opacity-50 pointer-events-none grayscale'>
|
||||
<h2 className='font-medium text-sm text-muted-foreground'>即将推出</h2>
|
||||
<div className='bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50'>
|
||||
<span className='text-sm text-muted-foreground'>
|
||||
更多自定义选项正在开发中...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import services from '@/lib/services';
|
||||
import type { AuthSource, AuthSourceRequest } from '@/lib/services/admin';
|
||||
|
||||
const emptyForm: AuthSourceRequest = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
display_name: "",
|
||||
name: '',
|
||||
type: 'oidc',
|
||||
display_name: '',
|
||||
is_active: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
openid_discovery_url: "",
|
||||
scopes: "openid profile email",
|
||||
icon_url: "",
|
||||
}
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
openid_discovery_url: '',
|
||||
scopes: 'openid profile email',
|
||||
icon_url: '',
|
||||
};
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
@@ -28,13 +34,13 @@ export function AuthSourceModal({
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
source: AuthSource | null
|
||||
onClose: () => void
|
||||
onChanged: () => Promise<void>
|
||||
isOpen: boolean;
|
||||
source: AuthSource | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -45,122 +51,152 @@ export function AuthSourceModal({
|
||||
display_name: source.display_name,
|
||||
is_active: source.is_active,
|
||||
client_id: source.client_id,
|
||||
client_secret: "",
|
||||
client_secret: '',
|
||||
openid_discovery_url: source.openid_discovery_url,
|
||||
scopes: source.scopes || "openid profile email",
|
||||
scopes: source.scopes || 'openid profile email',
|
||||
icon_url: source.icon_url,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
setForm(emptyForm);
|
||||
}
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
setSaving(false)
|
||||
setForm(emptyForm);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [isOpen, source])
|
||||
}, [isOpen, source]);
|
||||
|
||||
const saveSource = async () => {
|
||||
setSaving(true)
|
||||
setSaving(true);
|
||||
try {
|
||||
if (source) {
|
||||
await services.adminAuthSource.updateAuthSource(source.id, form)
|
||||
await services.adminAuthSource.updateAuthSource(source.id, form);
|
||||
} else {
|
||||
await services.adminAuthSource.createAuthSource(form)
|
||||
await services.adminAuthSource.createAuthSource(form);
|
||||
}
|
||||
await onChanged()
|
||||
toast.success("认证源已保存")
|
||||
onClose()
|
||||
await onChanged();
|
||||
toast.success('认证源已保存');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存认证源失败")
|
||||
toast.error(error instanceof Error ? error.message : '保存认证源失败');
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className='max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
|
||||
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
|
||||
{source
|
||||
? '修改系统自定义的 OIDC 认证源参数。'
|
||||
: '配置新的自定义 OIDC 认证源。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className='grid gap-4 md:grid-cols-2 pt-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>标识符 (英文名称)</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
disabled={!!source}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如: github"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder='例如: github'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>展示名称</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="例如: GitHub 登录"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, display_name: e.target.value }))
|
||||
}
|
||||
placeholder='例如: GitHub 登录'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
value={form.client_id}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, client_id: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>Client Secret</Label>
|
||||
<Input
|
||||
value={form.client_secret}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
|
||||
placeholder={source ? "留空则保留原值" : ""}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
|
||||
}
|
||||
placeholder={source ? '留空则保留原值' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<div className='space-y-2 md:col-span-2'>
|
||||
<Label>Discovery URL (OIDC 发行方 URL)</Label>
|
||||
<Input
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
openid_discovery_url: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='https://...'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>Scopes</Label>
|
||||
<Input
|
||||
value={form.scopes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, scopes: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>图标 URL (可选)</Label>
|
||||
<Input
|
||||
value={form.icon_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
|
||||
placeholder="https://... 或留空"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, icon_url: e.target.value }))
|
||||
}
|
||||
placeholder='https://... 或留空'
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10">
|
||||
<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="text-xs text-muted-foreground">启用后会立即显示在登录页和账号绑定中。</div>
|
||||
<div className='font-medium text-sm'>启用认证源</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
启用后会立即显示在登录页和账号绑定中。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
|
||||
<Switch
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({ ...prev, is_active: checked }))
|
||||
}
|
||||
/>
|
||||
</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}>
|
||||
<div className='md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2'>
|
||||
<Button variant='outline' type='button' onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
<Button
|
||||
type='button'
|
||||
onClick={saveSource}
|
||||
disabled={saving}
|
||||
variant='secondary'
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import services from "@/lib/services"
|
||||
import services from '@/lib/services';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,54 +14,64 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
|
||||
<div className='flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0'>
|
||||
<span className='text-xs text-muted-foreground'>{label}</span>
|
||||
<span className='text-right text-xs font-medium text-foreground break-all'>
|
||||
{value || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoTab() {
|
||||
const updateQuery = useQuery({
|
||||
queryKey: ["admin", "update"],
|
||||
queryKey: ['admin', 'update'],
|
||||
queryFn: () => services.adminStatus.getUpdateStatus(),
|
||||
refetchInterval: 30 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
const applyUpdateMutation = useMutation({
|
||||
mutationFn: () => services.adminStatus.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
toast.success("升级包已校验完成,服务正在重启")
|
||||
toast.success('升级包已校验完成,服务正在重启');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "应用升级失败")
|
||||
toast.error(error.message || '应用升级失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const update = updateQuery.data
|
||||
const update = updateQuery.data;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-muted p-1.5 text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='rounded-lg bg-muted p-1.5 text-muted-foreground'>
|
||||
<Sparkles className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">应用更新</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
应用更新
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
检查上游 GitHub Actions Release 并升级当前服务
|
||||
</CardDescription>
|
||||
</div>
|
||||
@@ -69,75 +79,109 @@ export function InfoTab() {
|
||||
{update?.update_available ? (
|
||||
<Badge>发现新版本</Badge>
|
||||
) : update ? (
|
||||
<Badge variant="secondary">已是最新</Badge>
|
||||
<Badge variant='secondary'>已是最新</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 pt-4">
|
||||
<CardContent className='flex flex-col gap-4 pt-4'>
|
||||
{updateQuery.isLoading ? (
|
||||
<div className="flex min-h-32 items-center justify-center">
|
||||
<div className='flex min-h-32 items-center justify-center'>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : 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 || "无法获取上游版本信息"}
|
||||
<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 || '无法获取上游版本信息'}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => updateQuery.refetch()}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => updateQuery.refetch()}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
) : update ? (
|
||||
<>
|
||||
<div>
|
||||
<InfoRow label="当前版本" value={update.current_version} />
|
||||
<InfoRow label="最新版本" value={update.latest_version} />
|
||||
{update.build_time && <InfoRow label="构建时间" value={update.build_time} />}
|
||||
<InfoRow label="运行平台" value={update.platform} />
|
||||
<InfoRow label="上游仓库" value={update.upstream_repository} />
|
||||
<InfoRow label="Release 资产" value={update.asset_name} />
|
||||
<InfoRow label='当前版本' value={update.current_version} />
|
||||
<InfoRow label='最新版本' value={update.latest_version} />
|
||||
{update.build_time && (
|
||||
<InfoRow label='构建时间' value={update.build_time} />
|
||||
)}
|
||||
<InfoRow label='运行平台' value={update.platform} />
|
||||
<InfoRow label='上游仓库' value={update.upstream_repository} />
|
||||
<InfoRow label='Release 资产' 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="max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
<div className='flex flex-col gap-2 rounded-md border border-dashed p-3'>
|
||||
<p className='text-xs font-medium'>更新说明</p>
|
||||
<p className='max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground'>
|
||||
{update.release_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => updateQuery.refetch()} disabled={updateQuery.isFetching}>
|
||||
{updateQuery.isFetching ? <Spinner data-icon="inline-start" /> : <RefreshCw data-icon="inline-start" />}
|
||||
<div className='flex flex-wrap justify-end gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => updateQuery.refetch()}
|
||||
disabled={updateQuery.isFetching}
|
||||
>
|
||||
{updateQuery.isFetching ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
)}
|
||||
检查更新
|
||||
</Button>
|
||||
{update.release_url && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={update.release_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<a
|
||||
href={update.release_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<ExternalLink data-icon='inline-start' />
|
||||
查看 Release
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" size="sm" disabled={!update.can_upgrade || applyUpdateMutation.isPending}>
|
||||
{applyUpdateMutation.isPending && <Spinner data-icon="inline-start" />}
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={
|
||||
!update.can_upgrade || applyUpdateMutation.isPending
|
||||
}
|
||||
>
|
||||
{applyUpdateMutation.isPending && (
|
||||
<Spinner data-icon='inline-start' />
|
||||
)}
|
||||
立即升级
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
升级到 {update.latest_version}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
服务将下载并校验 {update.asset_name}
|
||||
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
|
||||
<AlertDialogAction
|
||||
onClick={() => applyUpdateMutation.mutate()}
|
||||
>
|
||||
确认升级
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -146,12 +190,12 @@ export function InfoTab() {
|
||||
</div>
|
||||
|
||||
{!update.can_upgrade && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{update.current_version === "dev"
|
||||
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{update.current_version === 'dev'
|
||||
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
|
||||
: update.update_available
|
||||
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
|
||||
: "当前版本无需升级。"}
|
||||
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
|
||||
: '当前版本无需升级。'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -159,5 +203,5 @@ export function InfoTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,74 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import Link from "next/link"
|
||||
import { Bell } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useNotificationSettings } from "@/contexts/notification-settings-context"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import Link from 'next/link';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useNotificationSettings } from '@/contexts/notification-settings-context';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
export function NotificationsMain() {
|
||||
const { showBell, setShowBell } = useNotificationSettings()
|
||||
const { showBell, setShowBell } = useNotificationSettings();
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<div className='py-6 space-y-6'>
|
||||
<div className='font-semibold'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
<Link href='/settings' className='text-base text-primary'>
|
||||
设置
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
|
||||
<BreadcrumbPage className='text-base font-semibold'>
|
||||
通知设置
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h2 className='font-medium text-sm text-foreground'>通知显示</h2>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
设置通知相关的显示选项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
|
||||
<div className='flex items-center justify-between rounded-lg border p-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Bell className='size-5 text-primary' />
|
||||
<div className='space-y-0.5'>
|
||||
<Label
|
||||
htmlFor='show-bell'
|
||||
className='text-sm font-medium cursor-pointer'
|
||||
>
|
||||
显示通知铃铛
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
在顶部导航栏中显示通知铃铛图标
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-bell"
|
||||
id='show-bell'
|
||||
checked={showBell}
|
||||
onCheckedChange={setShowBell}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,158 +1,205 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useMemo} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {KeyRound, ShieldAlert, X} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {TemplatesManager} from "./templates"
|
||||
import {toast} from "sonner"
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { KeyRound, ShieldAlert, X } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { TemplatesManager } from './templates';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface OperationTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
configs: Record<string, SystemConfig>;
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
|
||||
}
|
||||
|
||||
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
export function OperationTab({
|
||||
configs,
|
||||
systemConfigsQuery,
|
||||
}: OperationTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const uploadTypesQuery = useQuery({
|
||||
queryKey: ["admin", "upload-types"],
|
||||
queryKey: ['admin', 'upload-types'],
|
||||
queryFn: () => services.adminSystemConfig.listUploadTypes(),
|
||||
})
|
||||
});
|
||||
|
||||
const updateWhitelistMutation = useMutation({
|
||||
mutationFn: async (newValue: string) => {
|
||||
const config = configs["file_access_whitelist"]
|
||||
const config = configs['file_access_whitelist'];
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: file_access_whitelist")
|
||||
throw new Error('缺少配置项: file_access_whitelist');
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
|
||||
value: newValue,
|
||||
description: config.description,
|
||||
})
|
||||
await services.adminSystemConfig.updateSystemConfig(
|
||||
'file_access_whitelist',
|
||||
{
|
||||
value: newValue,
|
||||
description: config.description,
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("文件访问白名单已更新")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('文件访问白名单已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新白名单失败")
|
||||
toast.error(error.message || '更新白名单失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const whitelistConfig = configs["file_access_whitelist"]
|
||||
const whitelistConfig = configs['file_access_whitelist'];
|
||||
const currentWhitelist = useMemo<string[]>(() => {
|
||||
if (!whitelistConfig?.value) return ["avatar"]
|
||||
if (!whitelistConfig?.value) return ['avatar'];
|
||||
try {
|
||||
const parsed = JSON.parse(whitelistConfig.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
const parsed = JSON.parse(whitelistConfig.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// 降级支持逗号分隔解析
|
||||
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
|
||||
return whitelistConfig.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return ["avatar"]
|
||||
}, [whitelistConfig?.value])
|
||||
return ['avatar'];
|
||||
}, [whitelistConfig?.value]);
|
||||
|
||||
const handleAddType = (type: string) => {
|
||||
if (!type || currentWhitelist.includes(type)) return
|
||||
const newWhitelist = [...currentWhitelist, type]
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
if (!type || currentWhitelist.includes(type)) return;
|
||||
const newWhitelist = [...currentWhitelist, type];
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist));
|
||||
};
|
||||
|
||||
const handleRemoveType = (typeToRemove: string) => {
|
||||
const newWhitelist = currentWhitelist.filter(t => t !== typeToRemove)
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
const newWhitelist = currentWhitelist.filter((t) => t !== typeToRemove);
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist));
|
||||
};
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = uploadTypesQuery.data ?? []
|
||||
return types.map(t => {
|
||||
let label = t
|
||||
if (t === "avatar") label = "头像 (avatar)"
|
||||
else if (t === "attachment") label = "附件 (attachment)"
|
||||
else if (t === "doc") label = "文档 (doc)"
|
||||
else if (t === "generic") label = "通用 (generic)"
|
||||
return { value: t, label }
|
||||
})
|
||||
}, [uploadTypesQuery.data])
|
||||
const types = uploadTypesQuery.data ?? [];
|
||||
return types.map((t) => {
|
||||
let label = t;
|
||||
if (t === 'avatar') label = '头像 (avatar)';
|
||||
else if (t === 'attachment') label = '附件 (attachment)';
|
||||
else if (t === 'doc') label = '文档 (doc)';
|
||||
else if (t === 'generic') label = '通用 (generic)';
|
||||
return { value: t, label };
|
||||
});
|
||||
}, [uploadTypesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className='space-y-6'>
|
||||
{/* 文件访问白名单设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<KeyRound className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<KeyRound className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
|
||||
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
文件访问权限控制
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">添加免鉴权类型:</span>
|
||||
<CardContent className='pt-6 space-y-4'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm font-medium text-muted-foreground'>
|
||||
添加免鉴权类型:
|
||||
</span>
|
||||
<Select
|
||||
value=""
|
||||
value=''
|
||||
onValueChange={handleAddType}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
|
||||
disabled={
|
||||
updateWhitelistMutation.isPending ||
|
||||
systemConfigsQuery.isPending ||
|
||||
uploadTypesQuery.isPending
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" size="sm">
|
||||
<SelectValue placeholder="选择业务类型..." />
|
||||
<SelectTrigger className='w-[200px]' size='sm'>
|
||||
<SelectValue placeholder='选择业务类型...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes
|
||||
.filter(t => !currentWhitelist.includes(t.value))
|
||||
.map(t => (
|
||||
.filter((t) => !currentWhitelist.includes(t.value))
|
||||
.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableTypes.filter(t => !currentWhitelist.includes(t.value)).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center">所有类型已添加</div>
|
||||
{availableTypes.filter(
|
||||
(t) => !currentWhitelist.includes(t.value),
|
||||
).length === 0 && (
|
||||
<div className='text-xs text-muted-foreground p-2 text-center'>
|
||||
所有类型已添加
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
<div className='rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ShieldAlert className='size-4 text-primary' />
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
当前免鉴权列表
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentWhitelist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentWhitelist.map(type => (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{currentWhitelist.map((type) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20"
|
||||
variant='secondary'
|
||||
className='px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20'
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
{availableTypes.find((t) => t.value === type)?.label ||
|
||||
type}
|
||||
<button
|
||||
type="button"
|
||||
type='button'
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={
|
||||
updateWhitelistMutation.isPending ||
|
||||
systemConfigsQuery.isPending
|
||||
}
|
||||
className='rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed'
|
||||
>
|
||||
<X className="size-3" />
|
||||
<X className='size-3' />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
白名单已空,所有类型文件的访问都将需要登录。
|
||||
</p>
|
||||
)}
|
||||
@@ -164,5 +211,5 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
{/* 通知模板管理 */}
|
||||
<TemplatesManager />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useMemo, ComponentType} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
Code,
|
||||
@@ -16,170 +16,265 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound
|
||||
} from "lucide-react"
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
readOnly?: boolean
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
name: string
|
||||
items: MenuItem[]
|
||||
name: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: "基础菜单",
|
||||
name: '基础菜单',
|
||||
items: [
|
||||
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
|
||||
{ path: "/files", label: "我的文件", description: "用户个人文件管理与上传", icon: FolderOpen },
|
||||
]
|
||||
{
|
||||
path: '/home',
|
||||
label: '首页',
|
||||
description: '系统控制台/个人首页',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
path: '/files',
|
||||
label: '我的文件',
|
||||
description: '用户个人文件管理与上传',
|
||||
icon: FolderOpen,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "管理菜单",
|
||||
name: '管理菜单',
|
||||
items: [
|
||||
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
|
||||
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
|
||||
{ path: "/admin/files", label: "存储管理", description: "管理系统文件存储和清理无用文件", icon: FolderOpen },
|
||||
{ path: "/admin/database", label: "数据管理", description: "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", icon: Database },
|
||||
{ path: "/admin/push", label: "通知推送", description: "配置和发送系统通知及推送消息", icon: Bell },
|
||||
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
|
||||
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
|
||||
]
|
||||
{
|
||||
path: '/admin/users',
|
||||
label: '用户管理',
|
||||
description: '查看和管理系统用户列表及其状态',
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
path: '/admin/tasks',
|
||||
label: '任务管理',
|
||||
description: '查看和调度系统异步及定时任务',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
path: '/admin/files',
|
||||
label: '存储管理',
|
||||
description: '管理系统文件存储和清理无用文件',
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
path: '/admin/database',
|
||||
label: '数据管理',
|
||||
description: '监控物理数据库状态、分页浏览表数据及交互式 SQL 查询',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
path: '/admin/push',
|
||||
label: '通知推送',
|
||||
description: '配置和发送系统通知及推送消息',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
path: '/admin/logs',
|
||||
label: '系统日志',
|
||||
description: '查看异步任务执行日志和系统运行情况',
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
path: '/admin/system',
|
||||
label: '系统配置',
|
||||
description: '管理和维护系统基础键值对配置',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
label: '系统设置',
|
||||
description: '配置安全验证、邮箱服务及目录显示',
|
||||
icon: Settings,
|
||||
readOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "文档菜单",
|
||||
name: '文档菜单',
|
||||
items: [
|
||||
{ path: "/admin/demo", label: "规范示例", description: "内置 UI 组件与设计规范的展示、调试与参考", icon: Code },
|
||||
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
|
||||
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
path: '/admin/demo',
|
||||
label: '规范示例',
|
||||
description: '内置 UI 组件与设计规范的展示、调试与参考',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
path: '/docs/api',
|
||||
label: '接口文档',
|
||||
description: '系统 Swagger 交互式 API 接口文档',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
path: '/docs/how-to-use',
|
||||
label: '使用文档',
|
||||
description: '面向开发与运营的部署使用指南',
|
||||
icon: FileText,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface OtherTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
configs: Record<string, SystemConfig>;
|
||||
}
|
||||
|
||||
export function OtherTab({ configs }: OtherTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const menuDisplayConfig = useMemo(() => {
|
||||
const raw = configs["menu_display_config"]?.value
|
||||
if (!raw) return {} as Record<string, boolean>
|
||||
const raw = configs['menu_display_config']?.value;
|
||||
if (!raw) return {} as Record<string, boolean>;
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, boolean>
|
||||
return JSON.parse(raw) as Record<string, boolean>;
|
||||
} catch {
|
||||
return {} as Record<string, boolean>
|
||||
return {} as Record<string, boolean>;
|
||||
}
|
||||
}, [configs])
|
||||
}, [configs]);
|
||||
|
||||
const updateMenuConfigMutation = useMutation({
|
||||
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
|
||||
const newConfig = { ...menuDisplayConfig, [path]: enabled }
|
||||
const currentCfg = configs["menu_display_config"]
|
||||
await services.adminSystemConfig.updateSystemConfig("menu_display_config", {
|
||||
value: JSON.stringify(newConfig),
|
||||
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||
})
|
||||
mutationFn: async ({
|
||||
path,
|
||||
enabled,
|
||||
}: {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
const newConfig = { ...menuDisplayConfig, [path]: enabled };
|
||||
const currentCfg = configs['menu_display_config'];
|
||||
await services.adminSystemConfig.updateSystemConfig(
|
||||
'menu_display_config',
|
||||
{
|
||||
value: JSON.stringify(newConfig),
|
||||
description:
|
||||
currentCfg?.description ||
|
||||
'目录显示配置(JSON 字符串,格式为 {url: enabled})',
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("目录显示配置已更新")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('目录显示配置已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
toast.error(error.message || '更新配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleMenuToggle = (path: string, checked: boolean) => {
|
||||
updateMenuConfigMutation.mutate({ path, enabled: checked })
|
||||
}
|
||||
updateMenuConfigMutation.mutate({ path, enabled: checked });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutList className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<LayoutList className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
目录显示管理
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<CardContent className='pt-6 space-y-6'>
|
||||
{MENU_GROUPS.map((group) => (
|
||||
<div key={group.name} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
|
||||
<div key={group.name} className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-xs font-semibold text-muted-foreground tracking-wider uppercase'>
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="h-px bg-border/40 flex-1" />
|
||||
<div className='h-px bg-border/40 flex-1' />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isReadOnly = !!item.readOnly
|
||||
const checked = menuDisplayConfig[item.path] !== false
|
||||
const Icon = item.icon;
|
||||
const isReadOnly = !!item.readOnly;
|
||||
const checked = menuDisplayConfig[item.path] !== false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm'
|
||||
>
|
||||
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-primary shrink-0" />}
|
||||
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||
<div className='space-y-1.5 flex-1 min-w-0 pr-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{Icon && (
|
||||
<Icon className='size-4 text-primary shrink-0' />
|
||||
)}
|
||||
<span className='font-medium text-sm text-foreground truncate'>
|
||||
{item.label}
|
||||
</span>
|
||||
{isReadOnly && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||
<span className='text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0'>
|
||||
不可隐藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
|
||||
<p className='text-xs text-muted-foreground leading-normal line-clamp-2'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={isReadOnly || updateMenuConfigMutation.isPending}
|
||||
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
|
||||
disabled={
|
||||
isReadOnly || updateMenuConfigMutation.isPending
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
handleMenuToggle(item.path, val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
Clock,
|
||||
Fingerprint,
|
||||
@@ -14,263 +19,309 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus
|
||||
} from "lucide-react"
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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';
|
||||
|
||||
const SECURITY_KEYS = [
|
||||
{
|
||||
key: "password_login_enabled",
|
||||
title: "允许密码登录",
|
||||
description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。",
|
||||
key: 'password_login_enabled',
|
||||
title: '允许密码登录',
|
||||
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
key: "registration_enabled",
|
||||
title: "允许注册",
|
||||
description: "关闭后系统将禁止新用户进行自主账号注册。",
|
||||
key: 'registration_enabled',
|
||||
title: '允许注册',
|
||||
description: '关闭后系统将禁止新用户进行自主账号注册。',
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
key: "password_register_enabled",
|
||||
title: "允许密码注册",
|
||||
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
|
||||
key: 'password_register_enabled',
|
||||
title: '允许密码注册',
|
||||
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
|
||||
icon: Fingerprint,
|
||||
},
|
||||
{
|
||||
key: "oidc_login_enabled",
|
||||
title: "允许 OIDC 登录",
|
||||
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
|
||||
key: 'oidc_login_enabled',
|
||||
title: '允许 OIDC 登录',
|
||||
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
key: "email_login_verification_enabled",
|
||||
title: "邮箱登录验证",
|
||||
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
|
||||
key: 'email_login_verification_enabled',
|
||||
title: '邮箱登录验证',
|
||||
description:
|
||||
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
key: "email_register_verification_enabled",
|
||||
title: "邮箱注册验证",
|
||||
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
|
||||
key: 'email_register_verification_enabled',
|
||||
title: '邮箱注册验证',
|
||||
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
|
||||
icon: Mail,
|
||||
},
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
interface SecurityTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
configs: Record<string, SystemConfig>;
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
|
||||
}
|
||||
|
||||
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
|
||||
|
||||
const [capCount, setCapCount] = useState("")
|
||||
const [capDifficulty, setCapDifficulty] = useState("")
|
||||
const [capSize, setCapSize] = useState("")
|
||||
const [capTTL, setCapTTL] = useState("")
|
||||
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||
const [capCount, setCapCount] = useState('');
|
||||
const [capDifficulty, setCapDifficulty] = useState('');
|
||||
const [capSize, setCapSize] = useState('');
|
||||
const [capTTL, setCapTTL] = useState('');
|
||||
const [capTokenTTL, setCapTokenTTL] = useState('');
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true);
|
||||
|
||||
const [sessionTTL, setSessionTTL] = useState("168")
|
||||
const [customHours, setCustomHours] = useState("")
|
||||
const [sessionTTL, setSessionTTL] = useState('168');
|
||||
const [customHours, setCustomHours] = useState('');
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "sources"],
|
||||
queryKey: ['auth', 'sources'],
|
||||
queryFn: () => services.adminAuthSource.listAuthSources(),
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
const cfgMap = configs
|
||||
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
|
||||
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
|
||||
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
|
||||
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
|
||||
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||
const cfgMap = configs;
|
||||
setCapCount(cfgMap['cap_challenge_count']?.value || '1');
|
||||
setCapDifficulty(cfgMap['cap_challenge_difficulty']?.value || '4');
|
||||
setCapSize(cfgMap['cap_challenge_size']?.value || '32');
|
||||
setCapTTL(cfgMap['cap_challenge_ttl_seconds']?.value || '600');
|
||||
setCapTokenTTL(cfgMap['cap_token_ttl_seconds']?.value || '1200');
|
||||
setCapAutoSolve(cfgMap['cap_auto_solve']?.value !== 'false');
|
||||
|
||||
// 初始化登录保持设置
|
||||
const ttlVal = cfgMap["login_session_ttl_hours"]?.value || "0"
|
||||
if (ttlVal === "0" || ttlVal === "168" || ttlVal === "720" || ttlVal === "-1") {
|
||||
setSessionTTL(ttlVal)
|
||||
setCustomHours("")
|
||||
const ttlVal = cfgMap['login_session_ttl_hours']?.value || '0';
|
||||
if (
|
||||
ttlVal === '0' ||
|
||||
ttlVal === '168' ||
|
||||
ttlVal === '720' ||
|
||||
ttlVal === '-1'
|
||||
) {
|
||||
setSessionTTL(ttlVal);
|
||||
setCustomHours('');
|
||||
} else {
|
||||
setSessionTTL("custom")
|
||||
setCustomHours(ttlVal)
|
||||
setSessionTTL('custom');
|
||||
setCustomHours(ttlVal);
|
||||
}
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
}, [systemConfigsQuery.data, configs]);
|
||||
|
||||
const updateTTLMutation = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
const config = configs["login_session_ttl_hours"]
|
||||
const config = configs['login_session_ttl_hours'];
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: login_session_ttl_hours")
|
||||
throw new Error('缺少配置项: login_session_ttl_hours');
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
|
||||
value: value,
|
||||
description: config.description,
|
||||
})
|
||||
await services.adminSystemConfig.updateSystemConfig(
|
||||
'login_session_ttl_hours',
|
||||
{
|
||||
value: value,
|
||||
description: config.description,
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("登录状态保持时间已更新")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('登录状态保持时间已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
toast.error(error.message || '更新配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleTTLChange = (val: string) => {
|
||||
setSessionTTL(val)
|
||||
if (val !== "custom") {
|
||||
updateTTLMutation.mutate(val)
|
||||
setSessionTTL(val);
|
||||
if (val !== 'custom') {
|
||||
updateTTLMutation.mutate(val);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomBlur = () => {
|
||||
const parsed = parseInt(customHours, 10)
|
||||
const parsed = parseInt(customHours, 10);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
toast.error("请输入有效的过期小时数(正整数)")
|
||||
toast.error('请输入有效的过期小时数(正整数)');
|
||||
// 重置为原本的值
|
||||
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
|
||||
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
|
||||
return
|
||||
const originalVal = configs['login_session_ttl_hours']?.value || '0';
|
||||
setCustomHours(
|
||||
originalVal === 'custom' ||
|
||||
['0', '168', '720', '-1'].includes(originalVal)
|
||||
? ''
|
||||
: originalVal,
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateTTLMutation.mutate(parsed.toString())
|
||||
}
|
||||
updateTTLMutation.mutate(parsed.toString());
|
||||
};
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
const config = configs[key];
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
throw new Error(`缺少配置项: ${key}`);
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
value: value ? 'true' : 'false',
|
||||
description: config.description,
|
||||
})
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("系统安全配置已更新")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('系统安全配置已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
toast.error(error.message || '更新配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const toggleSourceMutation = useMutation({
|
||||
mutationFn: async (source: AuthSource) => {
|
||||
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||
await services.adminAuthSource.toggleAuthSource(source.id, {
|
||||
is_active: !source.is_active,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源状态已更新")
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
});
|
||||
toast.success('认证源状态已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "切换状态失败")
|
||||
toast.error(error.message || '切换状态失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const deleteSourceMutation = useMutation({
|
||||
mutationFn: async (sourceId: string) => {
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId)
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源已删除")
|
||||
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
});
|
||||
toast.success('认证源已删除');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除认证源失败")
|
||||
toast.error(error.message || '删除认证源失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
updateConfigMutation.mutate({ key, value: checked })
|
||||
}
|
||||
updateConfigMutation.mutate({ key, value: checked });
|
||||
};
|
||||
|
||||
const saveCapMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "cap_challenge_count", value: capCount },
|
||||
{ key: "cap_challenge_difficulty", value: capDifficulty },
|
||||
{ key: "cap_challenge_size", value: capSize },
|
||||
{ key: "cap_challenge_ttl_seconds", value: capTTL },
|
||||
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
|
||||
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
|
||||
]
|
||||
{ key: 'cap_challenge_count', value: capCount },
|
||||
{ key: 'cap_challenge_difficulty', value: capDifficulty },
|
||||
{ key: 'cap_challenge_size', value: capSize },
|
||||
{ key: 'cap_challenge_ttl_seconds', value: capTTL },
|
||||
{ key: 'cap_token_ttl_seconds', value: capTokenTTL },
|
||||
{ key: 'cap_auto_solve', value: capAutoSolve ? 'true' : 'false' },
|
||||
];
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
const currentCfg = configs[update.key];
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
description: currentCfg?.description || '',
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("人机验证配置已成功保存")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('人机验证配置已成功保存');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
toast.error(error.message || '保存配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleCapSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveCapMutation.mutate()
|
||||
}
|
||||
e.preventDefault();
|
||||
saveCapMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* 系统登录与注册控制 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Settings className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<Settings className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
系统登录/注册设置
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统的登录限制与用户自主注册权限
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CardContent className='pt-6'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||
{SECURITY_KEYS.map((item) => {
|
||||
const config = configs[item.key]
|
||||
const checked = config ? config.value === "true" : false
|
||||
const Icon = item.icon
|
||||
const config = configs[item.key];
|
||||
const checked = config ? config.value === 'true' : false;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm'
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-primary" />}
|
||||
<span className="font-medium text-sm text-foreground">{item.title}</span>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{Icon && <Icon className='size-4 text-primary' />}
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
|
||||
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
@@ -278,56 +329,56 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
onCheckedChange={(value) => handleToggle(item.key, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 登录状态保持时间 (选择后立即更改) */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
>
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
|
||||
<div className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm md:col-span-2'>
|
||||
<div className='space-y-1 pr-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock className='size-4 text-primary' />
|
||||
<span className='font-medium text-sm text-foreground'>
|
||||
登录状态保持时间
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
|
||||
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className='flex items-center gap-2 flex-shrink-0'>
|
||||
<Select
|
||||
value={sessionTTL}
|
||||
disabled={updateTTLMutation.isPending}
|
||||
onValueChange={handleTTLChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-card border-dashed text-xs h-8">
|
||||
<SelectValue placeholder="选择保留时间" />
|
||||
<SelectTrigger className='w-[180px] bg-card border-dashed text-xs h-8'>
|
||||
<SelectValue placeholder='选择保留时间' />
|
||||
</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'>关闭 (浏览器关闭自动退登)</SelectItem>
|
||||
<SelectItem value='168'>7 天</SelectItem>
|
||||
<SelectItem value='720'>30 天</SelectItem>
|
||||
<SelectItem value='-1'>永不过期</SelectItem>
|
||||
<SelectItem value='custom'>自定义时长</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sessionTTL === "custom" && (
|
||||
{sessionTTL === 'custom' && (
|
||||
<Input
|
||||
type="number"
|
||||
type='number'
|
||||
min={1}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
onBlur={handleCustomBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCustomBlur()
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomBlur();
|
||||
}
|
||||
}}
|
||||
placeholder="小时"
|
||||
placeholder='小时'
|
||||
disabled={updateTTLMutation.isPending}
|
||||
className="w-20 bg-card border-dashed text-xs h-8 px-2"
|
||||
className='w-20 bg-card border-dashed text-xs h-8 px-2'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,116 +388,134 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
</Card>
|
||||
|
||||
{/* 认证源配置管理 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Globe className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<Globe className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
|
||||
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
认证源管理
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
添加、修改并启用系统自定义的 OIDC 认证源
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
setSelectedSource(null);
|
||||
setAuthSourceModalOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
variant='secondary'
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增认证源
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<CardContent className='pt-6 space-y-3'>
|
||||
{authSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
<div className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='size-6 animate-spin text-muted-foreground/50' />
|
||||
</div>
|
||||
) : (authSourcesQuery.data ?? []).length > 0 ? (
|
||||
(authSourcesQuery.data ?? []).map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
className='flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm'
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{source.display_name || source.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
source.is_active
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{source.is_active ? "已启用" : "已禁用"}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-semibold text-sm text-foreground'>
|
||||
{source.display_name || source.name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
source.is_active
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{source.is_active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
<div className='text-xs text-muted-foreground font-mono'>
|
||||
标识符: {source.name} · 类型: {source.type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? "bg-primary/5 text-primary border-primary/10"
|
||||
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
|
||||
}`}>
|
||||
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
|
||||
<div className='flex items-center gap-4'>
|
||||
<span
|
||||
className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? 'bg-primary/5 text-primary border-primary/10'
|
||||
: 'bg-rose-500/5 text-rose-500 border-rose-500/10'
|
||||
}`}
|
||||
>
|
||||
{source.client_secret_configured
|
||||
? 'Secret 已配置'
|
||||
: 'Secret 未配置'}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={source.is_active}
|
||||
disabled={toggleSourceMutation.isPending}
|
||||
className="scale-90 mr-2"
|
||||
onCheckedChange={() => toggleSourceMutation.mutate(source)}
|
||||
className='scale-90 mr-2'
|
||||
onCheckedChange={() =>
|
||||
toggleSourceMutation.mutate(source)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
|
||||
onClick={() => {
|
||||
setSelectedSource(source)
|
||||
setAuthSourceModalOpen(true)
|
||||
setSelectedSource(source);
|
||||
setAuthSourceModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
<Pencil className='size-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors'
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除认证源「${source.display_name || source.name}」吗?`)) {
|
||||
deleteSourceMutation.mutate(source.id)
|
||||
if (
|
||||
window.confirm(
|
||||
`确定删除认证源「${source.display_name || source.name}」吗?`,
|
||||
)
|
||||
) {
|
||||
deleteSourceMutation.mutate(source.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
setSelectedSource(null);
|
||||
setAuthSourceModalOpen(true);
|
||||
}}
|
||||
className="border-dashed"
|
||||
className='border-dashed'
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增认证源
|
||||
</Button>
|
||||
</div>
|
||||
@@ -455,123 +524,163 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
</Card>
|
||||
|
||||
{/* 人机验证配置 (Cap CAPTCHA) */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Shield className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<Shield className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
|
||||
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
人机验证配置 (Cap CAPTCHA)
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置基于 Proof-of-Work (PoW)
|
||||
的无感人机验证,保护系统登录免受暴力破解和撞库攻击
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={configs["cap_login_enabled"]?.value === "true"}
|
||||
checked={configs['cap_login_enabled']?.value === 'true'}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle('cap_login_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className='pt-6'>
|
||||
{/* 自动开始计算 Switch */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
|
||||
<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>
|
||||
</div>
|
||||
<Switch
|
||||
checked={capAutoSolve}
|
||||
onCheckedChange={setCapAutoSolve}
|
||||
/>
|
||||
<Switch checked={capAutoSolve} onCheckedChange={setCapAutoSolve} />
|
||||
</div>
|
||||
<form onSubmit={handleCapSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
|
||||
<form onSubmit={handleCapSave} className='space-y-6'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='cap_challenge_count'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
难题数量 (Count)
|
||||
</Label>
|
||||
<Input
|
||||
id="cap_challenge_count"
|
||||
type="number"
|
||||
id='cap_challenge_count'
|
||||
type='number'
|
||||
min={1}
|
||||
max={100}
|
||||
value={capCount}
|
||||
onChange={(e) => setCapCount(e.target.value)}
|
||||
placeholder="50"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='50'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
客户端需求解的难题总数。默认 1,推荐 1 至 5
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='cap_challenge_difficulty'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
验证难度 (Difficulty)
|
||||
</Label>
|
||||
<Input
|
||||
id="cap_challenge_difficulty"
|
||||
type="number"
|
||||
id='cap_challenge_difficulty'
|
||||
type='number'
|
||||
min={1}
|
||||
max={10}
|
||||
value={capDifficulty}
|
||||
onChange={(e) => setCapDifficulty(e.target.value)}
|
||||
placeholder="4"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='4'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='cap_challenge_size'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
盐值长度 (Size)
|
||||
</Label>
|
||||
<Input
|
||||
id="cap_challenge_size"
|
||||
type="number"
|
||||
id='cap_challenge_size'
|
||||
type='number'
|
||||
min={8}
|
||||
max={64}
|
||||
value={capSize}
|
||||
onChange={(e) => setCapSize(e.target.value)}
|
||||
placeholder="32"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='32'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
难题盐值混淆字符长度。默认 32
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='cap_challenge_ttl'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
难题超时时长 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="cap_challenge_ttl"
|
||||
type="number"
|
||||
id='cap_challenge_ttl'
|
||||
type='number'
|
||||
min={10}
|
||||
value={capTTL}
|
||||
onChange={(e) => setCapTTL(e.target.value)}
|
||||
placeholder="600"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='600'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
难题有效期限。默认 600 秒 (10 分钟)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
|
||||
<div className='space-y-1.5 sm:col-span-2'>
|
||||
<Label
|
||||
htmlFor='cap_token_ttl'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
验证凭证有效时长 (秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="cap_token_ttl"
|
||||
type="number"
|
||||
id='cap_token_ttl'
|
||||
type='number'
|
||||
min={10}
|
||||
value={capTokenTTL}
|
||||
onChange={(e) => setCapTokenTTL(e.target.value)}
|
||||
placeholder="1200"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='1200'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20
|
||||
分钟)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||
<div className='flex justify-end pt-4 border-t border-dashed'>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={saveCapMutation.isPending}
|
||||
>
|
||||
{saveCapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
'保存配置'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -584,11 +693,15 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
source={selectedSource}
|
||||
onClose={() => setAuthSourceModalOpen(false)}
|
||||
onChanged={async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
await authSourcesQuery.refetch()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'sources'],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['auth', 'public-sources'],
|
||||
});
|
||||
await authSourcesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,152 +1,178 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {Globe, Loader2, Mail, Search, Server, Sparkles} from "lucide-react"
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { Globe, Loader2, Mail, Search, Server, Sparkles } from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import services from '@/lib/services';
|
||||
import type { SystemConfig } from '@/lib/services/admin';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SystemTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
configs: Record<string, SystemConfig>;
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
|
||||
}
|
||||
|
||||
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [serverAddress, setServerAddress] = useState("")
|
||||
const [siteName, setSiteName] = useState("")
|
||||
const [smtpHost, setSmtpHost] = useState("")
|
||||
const [smtpPort, setSmtpPort] = useState("")
|
||||
const [smtpUsername, setSmtpUsername] = useState("")
|
||||
const [smtpPassword, setSmtpPassword] = useState("")
|
||||
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
|
||||
const [smtpTestTo, setSmtpTestTo] = useState("")
|
||||
const [smtpTestLog, setSmtpTestLog] = useState("")
|
||||
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
|
||||
const [smtpTestError, setSmtpTestError] = useState("")
|
||||
const queryClient = useQueryClient();
|
||||
const [serverAddress, setServerAddress] = useState('');
|
||||
const [siteName, setSiteName] = useState('');
|
||||
const [smtpHost, setSmtpHost] = useState('');
|
||||
const [smtpPort, setSmtpPort] = useState('');
|
||||
const [smtpUsername, setSmtpUsername] = useState('');
|
||||
const [smtpPassword, setSmtpPassword] = useState('');
|
||||
const [smtpTestOpen, setSmtpTestOpen] = useState(false);
|
||||
const [smtpTestTo, setSmtpTestTo] = useState('');
|
||||
const [smtpTestLog, setSmtpTestLog] = useState('');
|
||||
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null);
|
||||
const [smtpTestError, setSmtpTestError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
setServerAddress(configs["server_address"]?.value || "")
|
||||
setSiteName(configs["site_name"]?.value || "")
|
||||
setSmtpHost(configs["smtp_host"]?.value || "")
|
||||
setSmtpPort(configs["smtp_port"]?.value || "587")
|
||||
setSmtpUsername(configs["smtp_username"]?.value || "")
|
||||
setSmtpPassword(configs["smtp_password"]?.value || "")
|
||||
setServerAddress(configs['server_address']?.value || '');
|
||||
setSiteName(configs['site_name']?.value || '');
|
||||
setSmtpHost(configs['smtp_host']?.value || '');
|
||||
setSmtpPort(configs['smtp_port']?.value || '587');
|
||||
setSmtpUsername(configs['smtp_username']?.value || '');
|
||||
setSmtpPassword(configs['smtp_password']?.value || '');
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
}, [systemConfigsQuery.data, configs]);
|
||||
|
||||
const handleDetectAddress = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
setServerAddress(window.location.origin)
|
||||
toast.success("已自动获取当前域名并填充")
|
||||
if (typeof window !== 'undefined') {
|
||||
setServerAddress(window.location.origin);
|
||||
toast.success('已自动获取当前域名并填充');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
const config = configs[key];
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
throw new Error(`缺少配置项: ${key}`);
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
value: value ? 'true' : 'false',
|
||||
description: config.description,
|
||||
})
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("配置已更新")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
toast.success('配置已更新');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
toast.error(error.message || '更新配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const saveSystemMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const currentAddrCfg = configs["server_address"]
|
||||
const currentSiteCfg = configs["site_name"]
|
||||
const currentAddrCfg = configs['server_address'];
|
||||
const currentSiteCfg = configs['site_name'];
|
||||
await Promise.all([
|
||||
services.adminSystemConfig.updateSystemConfig("server_address", {
|
||||
services.adminSystemConfig.updateSystemConfig('server_address', {
|
||||
value: serverAddress,
|
||||
description: currentAddrCfg?.description || "服务器地址",
|
||||
description: currentAddrCfg?.description || '服务器地址',
|
||||
}),
|
||||
services.adminSystemConfig.updateSystemConfig("site_name", {
|
||||
services.adminSystemConfig.updateSystemConfig('site_name', {
|
||||
value: siteName,
|
||||
description: currentSiteCfg?.description || "系统平台的展示名称",
|
||||
description: currentSiteCfg?.description || '系统平台的展示名称',
|
||||
}),
|
||||
])
|
||||
]);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
|
||||
])
|
||||
toast.success("通用配置已成功保存")
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
|
||||
]);
|
||||
toast.success('通用配置已成功保存');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
toast.error(error.message || '保存配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSystemSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSystemMutation.mutate()
|
||||
}
|
||||
e.preventDefault();
|
||||
saveSystemMutation.mutate();
|
||||
};
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "smtp_host", value: smtpHost },
|
||||
{ key: "smtp_port", value: smtpPort },
|
||||
{ key: "smtp_username", value: smtpUsername },
|
||||
{ key: "smtp_password", value: smtpPassword },
|
||||
]
|
||||
{ key: 'smtp_host', value: smtpHost },
|
||||
{ key: 'smtp_port', value: smtpPort },
|
||||
{ key: 'smtp_username', value: smtpUsername },
|
||||
{ key: 'smtp_password', value: smtpPassword },
|
||||
];
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
|
||||
const currentCfg = configs[update.key];
|
||||
if (
|
||||
update.key === 'smtp_password' &&
|
||||
(update.value === '' || update.value === '******')
|
||||
) {
|
||||
// If already configured and sent empty or mask, skip updating it (keep existing)
|
||||
if (currentCfg && currentCfg.value === "******") {
|
||||
continue
|
||||
if (currentCfg && currentCfg.value === '******') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
description: currentCfg?.description || '',
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("SMTP 邮件配置已成功保存")
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('SMTP 邮件配置已成功保存');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
toast.error(error.message || '保存配置失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSmtpSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSmtpMutation.mutate()
|
||||
}
|
||||
e.preventDefault();
|
||||
saveSmtpMutation.mutate();
|
||||
};
|
||||
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSmtpTestLog("正在发起连接测试...\n")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
setSmtpTestLog('正在发起连接测试...\n');
|
||||
setSmtpTestSuccess(null);
|
||||
setSmtpTestError('');
|
||||
|
||||
const res = await services.adminSystemConfig.testSMTP({
|
||||
smtp_host: smtpHost,
|
||||
@@ -154,151 +180,167 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
smtp_username: smtpUsername,
|
||||
smtp_password: smtpPassword,
|
||||
to: smtpTestTo,
|
||||
})
|
||||
return res
|
||||
});
|
||||
return res;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSmtpTestLog(data.log)
|
||||
setSmtpTestLog(data.log);
|
||||
if (data.success) {
|
||||
setSmtpTestSuccess(true)
|
||||
toast.success("测试邮件发送成功")
|
||||
setSmtpTestSuccess(true);
|
||||
toast.success('测试邮件发送成功');
|
||||
} else {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
|
||||
toast.error("测试邮件发送失败")
|
||||
setSmtpTestSuccess(false);
|
||||
setSmtpTestError(data.error || '发送失败,请检查配置和日志。');
|
||||
toast.error('测试邮件发送失败');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(error.message || "请求发送失败")
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
|
||||
toast.error(error.message || "测试请求发送失败")
|
||||
setSmtpTestSuccess(false);
|
||||
setSmtpTestError(error.message || '请求发送失败');
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
|
||||
toast.error(error.message || '测试请求发送失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (!smtpTestTo) {
|
||||
toast.error("请输入目标邮箱地址")
|
||||
return
|
||||
toast.error('请输入目标邮箱地址');
|
||||
return;
|
||||
}
|
||||
testSmtpMutation.mutate()
|
||||
}
|
||||
testSmtpMutation.mutate();
|
||||
};
|
||||
|
||||
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
|
||||
const indexingEnabled =
|
||||
configs['search_engine_indexing_enabled']?.value === 'true';
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className='space-y-8'>
|
||||
{/* 通用设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 text-primary">
|
||||
<Server className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='rounded-lg bg-primary/10 p-1.5 text-primary'>
|
||||
<Server className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
通用设置
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置站点标识、服务地址与搜索引擎收录策略
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSystemSave} className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="site_name" className="text-xs font-semibold">
|
||||
<CardContent className='pt-6'>
|
||||
<form onSubmit={handleSystemSave} className='flex flex-col gap-6'>
|
||||
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor='site_name' className='text-xs font-semibold'>
|
||||
站点名称
|
||||
</Label>
|
||||
<Input
|
||||
id="site_name"
|
||||
type="text"
|
||||
id='site_name'
|
||||
type='text'
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder="例如: Wavelet"
|
||||
className="border-dashed bg-card text-xs"
|
||||
placeholder='例如: Wavelet'
|
||||
className='border-dashed bg-card text-xs'
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
用于网页标题、登录注册页面、管理后台与系统邮件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="server_address" className="text-xs font-semibold">
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Label
|
||||
htmlFor='server_address'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
服务器访问地址
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
onClick={handleDetectAddress}
|
||||
className="h-auto px-0 text-xs"
|
||||
className='h-auto px-0 text-xs'
|
||||
>
|
||||
<Sparkles data-icon="inline-start" />
|
||||
<Sparkles data-icon='inline-start' />
|
||||
使用当前域名
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="server_address"
|
||||
type="url"
|
||||
id='server_address'
|
||||
type='url'
|
||||
value={serverAddress}
|
||||
onChange={(e) => setServerAddress(e.target.value)}
|
||||
placeholder="例如: https://example.com"
|
||||
className="border-dashed bg-card text-xs"
|
||||
placeholder='例如: https://example.com'
|
||||
className='border-dashed bg-card text-xs'
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
限定 API 的允许来源;留空将开放任意来源访问。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="rounded-md bg-muted p-1.5 text-muted-foreground">
|
||||
<Search className="size-4" />
|
||||
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<div className='rounded-md bg-muted p-1.5 text-muted-foreground'>
|
||||
<Search className='size-4' />
|
||||
</div>
|
||||
<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">搜索引擎抓取与收录</span>
|
||||
<Badge variant={indexingEnabled ? "default" : "secondary"}>
|
||||
{indexingEnabled ? "允许收录" : "禁止收录"}
|
||||
<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'>
|
||||
搜索引擎抓取与收录
|
||||
</span>
|
||||
<Badge variant={indexingEnabled ? 'default' : 'secondary'}>
|
||||
{indexingEnabled ? '允许收录' : '禁止收录'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<p className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
aria-label="允许搜索引擎抓取与收录"
|
||||
aria-label='允许搜索引擎抓取与收录'
|
||||
checked={indexingEnabled}
|
||||
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
|
||||
disabled={
|
||||
updateConfigMutation.isPending || systemConfigsQuery.isPending
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked })
|
||||
updateConfigMutation.mutate({
|
||||
key: 'search_engine_indexing_enabled',
|
||||
value: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<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' />
|
||||
地址变更会影响跨域访问策略,请填写完整协议与域名。
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={saveSystemMutation.isPending}
|
||||
>
|
||||
{saveSystemMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||
<Loader2
|
||||
data-icon='inline-start'
|
||||
className='animate-spin'
|
||||
/>
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
'保存配置'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -307,98 +349,120 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</Card>
|
||||
|
||||
{/* SMTP 邮件设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Mail className="size-4" />
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<Mail className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
SMTP 邮件设置
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置系统的邮件发送服务 (SMTP)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSmtpSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
|
||||
<CardContent className='pt-6'>
|
||||
<form onSubmit={handleSmtpSave} className='space-y-6'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_host' className='text-xs font-semibold'>
|
||||
SMTP 服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp_host"
|
||||
type="text"
|
||||
id='smtp_host'
|
||||
type='text'
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="例如: smtp.example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: smtp.example.com'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
|
||||
SMTP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp_port"
|
||||
type="number"
|
||||
id='smtp_port'
|
||||
type='number'
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
placeholder="例如: 587 或 465"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: 587 或 465'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='smtp_username'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
SMTP 账户
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp_username"
|
||||
type="text"
|
||||
id='smtp_username'
|
||||
type='text'
|
||||
value={smtpUsername}
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
placeholder="例如: sender@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: sender@example.com'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='smtp_password'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
SMTP 访问凭证
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp_password"
|
||||
type="password"
|
||||
id='smtp_password'
|
||||
type='password'
|
||||
value={smtpPassword}
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder={
|
||||
configs['smtp_password']?.value === '******'
|
||||
? '•••••• (已配置,留空或输入新值)'
|
||||
: '输入凭证密码'
|
||||
}
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
|
||||
<div className='flex justify-end gap-2 pt-4 border-t border-dashed'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSmtpTestOpen(true)
|
||||
setSmtpTestTo("")
|
||||
setSmtpTestLog("")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
setSmtpTestOpen(true);
|
||||
setSmtpTestTo('');
|
||||
setSmtpTestLog('');
|
||||
setSmtpTestSuccess(null);
|
||||
setSmtpTestError('');
|
||||
}}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
测试发件
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
{saveSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
'保存配置'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -407,72 +471,77 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</Card>
|
||||
|
||||
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
|
||||
<DialogContent className="max-w-lg border border-dashed">
|
||||
<DialogContent className='max-w-lg border border-dashed'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
SMTP 发件测试
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs'>
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP
|
||||
配置进行发件测试。
|
||||
</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">目标邮箱地址</Label>
|
||||
<form onSubmit={handleSmtpTestSubmit} className='space-y-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='smtp_test_to' className='text-xs font-semibold'>
|
||||
目标邮箱地址
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp_test_to"
|
||||
type="email"
|
||||
id='smtp_test_to'
|
||||
type='email'
|
||||
required
|
||||
value={smtpTestTo}
|
||||
onChange={(e) => setSmtpTestTo(e.target.value)}
|
||||
placeholder="例如: receiver@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: receiver@example.com'
|
||||
className='bg-card border-dashed text-xs'
|
||||
disabled={testSmtpMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{smtpTestLog && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">连接与传输日志</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">
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>连接与传输日志</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === true && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
|
||||
<div className='p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs'>
|
||||
测试成功!邮件已顺利发出。
|
||||
</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">
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setSmtpTestOpen(false)}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
"开始测试"
|
||||
'开始测试'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -480,5 +549,5 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import {useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import services from "@/lib/services"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import services from '@/lib/services';
|
||||
import type { Template } from '@/lib/services/admin/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function TemplatesManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Form states
|
||||
const [key, setKey] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState("email")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [key, setKey] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState('email');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["admin", "templates"],
|
||||
queryKey: ['admin', 'templates'],
|
||||
queryFn: () => services.adminTemplate.listTemplates(),
|
||||
})
|
||||
});
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -41,17 +56,17 @@ export function TemplatesManager() {
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功创建")
|
||||
setModalOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已成功创建');
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建模板失败")
|
||||
toast.error(error.message || '创建模板失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
@@ -61,164 +76,178 @@ export function TemplatesManager() {
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功保存")
|
||||
setModalOpen(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已成功保存');
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改模板失败")
|
||||
toast.error(error.message || '修改模板失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await services.adminTemplate.deleteTemplate(key)
|
||||
await services.adminTemplate.deleteTemplate(key);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已删除")
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
|
||||
toast.success('通知模板已删除');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除模板失败")
|
||||
toast.error(error.message || '删除模板失败');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSelectedTemplate(null)
|
||||
setKey("")
|
||||
setName("")
|
||||
setType("email")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
setDescription("")
|
||||
setModalOpen(true)
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setKey('');
|
||||
setName('');
|
||||
setType('email');
|
||||
setSubject('');
|
||||
setContent('');
|
||||
setDescription('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (tmpl: Template) => {
|
||||
setSelectedTemplate(tmpl)
|
||||
setKey(tmpl.key)
|
||||
setName(tmpl.name)
|
||||
setType(tmpl.type)
|
||||
setSubject(tmpl.subject || "")
|
||||
setContent(tmpl.content)
|
||||
setDescription(tmpl.description || "")
|
||||
setModalOpen(true)
|
||||
}
|
||||
setSelectedTemplate(tmpl);
|
||||
setKey(tmpl.key);
|
||||
setName(tmpl.name);
|
||||
setType(tmpl.type);
|
||||
setSubject(tmpl.subject || '');
|
||||
setContent(tmpl.content);
|
||||
setDescription(tmpl.description || '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (selectedTemplate) {
|
||||
updateTemplateMutation.mutate(selectedTemplate.key)
|
||||
updateTemplateMutation.mutate(selectedTemplate.key);
|
||||
} else {
|
||||
createTemplateMutation.mutate()
|
||||
createTemplateMutation.mutate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
|
||||
const isPending =
|
||||
createTemplateMutation.isPending || updateTemplateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="size-4" />
|
||||
<div className='space-y-6'>
|
||||
<Card className='border border-dashed shadow-sm'>
|
||||
<CardHeader className='border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='p-1.5 rounded-lg bg-primary/10 text-primary'>
|
||||
<FileText className='size-4' />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
通知模板管理
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={handleOpenCreate}
|
||||
variant="secondary"
|
||||
variant='secondary'
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增模板
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<CardContent className='pt-6 space-y-4'>
|
||||
{templatesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
<div className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='size-6 animate-spin text-muted-foreground/50' />
|
||||
</div>
|
||||
) : (templatesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
className='flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm'
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<span className='font-semibold text-sm text-foreground'>
|
||||
{tmpl.name}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? 'bg-primary/10 text-primary border-primary/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{tmpl.is_system ? '系统内置' : '自定义'}
|
||||
</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">
|
||||
标识符: <span className="font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
标识符:{' '}
|
||||
<span className='font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded'>
|
||||
{tmpl.key}
|
||||
</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed max-w-xl">
|
||||
<p className='text-xs text-muted-foreground/80 leading-relaxed max-w-xl'>
|
||||
{tmpl.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 shrink-0">
|
||||
<div className='flex items-center justify-end gap-2 shrink-0'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
<Pencil className='size-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={tmpl.is_system || deleteTemplateMutation.isPending}
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors'
|
||||
disabled={
|
||||
tmpl.is_system || deleteTemplateMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除模板「${tmpl.name}」吗?`)) {
|
||||
deleteTemplateMutation.mutate(tmpl.key)
|
||||
if (
|
||||
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
|
||||
) {
|
||||
deleteTemplateMutation.mutate(tmpl.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={handleOpenCreate}
|
||||
className="border-dashed"
|
||||
className='border-dashed'
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
<Plus className='mr-1.5 size-3.5' />
|
||||
新增模板
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,142 +256,142 @@ export function TemplatesManager() {
|
||||
</Card>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-xl border border-dashed">
|
||||
<DialogContent className='max-w-xl border border-dashed'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
|
||||
<DialogTitle className='text-base font-semibold'>
|
||||
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
|
||||
<DialogDescription className='text-xs'>
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `
|
||||
{'{{.Code}}'}` 表示验证码变量)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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">
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<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)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_key"
|
||||
type="text"
|
||||
id='tmpl_key'
|
||||
type='text'
|
||||
required
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="例如: login_email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: login_email'
|
||||
className='bg-card border-dashed text-xs'
|
||||
disabled={!!selectedTemplate}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
模板的唯一标识,在代码中通过此 Key 调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_name" className="text-xs font-semibold">
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
|
||||
模板名称
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_name"
|
||||
type="text"
|
||||
id='tmpl_name'
|
||||
type='text'
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如: 登录验证码邮件"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: 登录验证码邮件'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
用于后台识别该模板的可读名称。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_type" className="text-xs font-semibold">
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
|
||||
模板类型
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_type"
|
||||
type="text"
|
||||
id='tmpl_type'
|
||||
type='text'
|
||||
required
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='email'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
模板分类标识,目前支持 `email`。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_subject" className="text-xs font-semibold">
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
|
||||
模板主题 (Subject)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_subject"
|
||||
type="text"
|
||||
id='tmpl_subject'
|
||||
type='text'
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="例如: Wavelet 登录验证码"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: Wavelet 登录验证码'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
邮件标题(类型为 email 时生效)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_description" className="text-xs font-semibold">
|
||||
<div className='space-y-1.5'>
|
||||
<Label
|
||||
htmlFor='tmpl_description'
|
||||
className='text-xs font-semibold'
|
||||
>
|
||||
模板说明与变量描述
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_description"
|
||||
type="text"
|
||||
id='tmpl_description'
|
||||
type='text'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
|
||||
className="bg-card border-dashed text-xs"
|
||||
placeholder='例如: 包含变量:{{.Code}},5分钟内有效'
|
||||
className='bg-card border-dashed text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_content" className="text-xs font-semibold">
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
|
||||
模板正文内容 (Content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tmpl_content"
|
||||
id='tmpl_content'
|
||||
required
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
|
||||
placeholder='<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>'
|
||||
rows={8}
|
||||
className="bg-card border-dashed text-xs font-mono"
|
||||
className='bg-card border-dashed text-xs font-mono'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type='submit' size='sm' disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
'保存配置'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -370,5 +399,5 @@ export function TemplatesManager() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card, CardContent} from "@/components/ui/card"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -34,232 +34,264 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import {FileImagePreview} from "@/components/common/file-image-preview"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
import { FileImagePreview } from '@/components/common/file-image-preview';
|
||||
import services, { formatFileSize } from '@/lib/services';
|
||||
import type { Upload as UploadRecord } from '@/lib/services/upload/types';
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
|
||||
function getFileIcon(mimeType: string, className = "size-10") {
|
||||
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
|
||||
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
|
||||
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
|
||||
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
|
||||
return <FileArchive className={`${className} text-amber-400`} />
|
||||
return <FileText className={`${className} text-slate-400`} />
|
||||
function getFileIcon(mimeType: string, className = 'size-10') {
|
||||
if (mimeType.startsWith('image/'))
|
||||
return <FileImage className={`${className} text-blue-400`} />;
|
||||
if (mimeType.startsWith('video/'))
|
||||
return <FileVideo className={`${className} text-purple-400`} />;
|
||||
if (mimeType.startsWith('audio/'))
|
||||
return <FileAudio className={`${className} text-green-400`} />;
|
||||
if (
|
||||
mimeType.includes('zip') ||
|
||||
mimeType.includes('tar') ||
|
||||
mimeType.includes('gzip')
|
||||
)
|
||||
return <FileArchive className={`${className} text-amber-400`} />;
|
||||
return <FileText className={`${className} text-slate-400`} />;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
return new Date(dateStr).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function UserFileManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = React.useState("")
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
||||
const [page, setPage] = React.useState(1)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
|
||||
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0) // 默认私有
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const pageSize = 12 // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
|
||||
const queryClient = useQueryClient();
|
||||
const [keyword, setKeyword] = React.useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0); // 默认私有
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const pageSize = 12; // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
|
||||
|
||||
// 搜索防抖
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword)
|
||||
setPage(1)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
setDebouncedKeyword(keyword);
|
||||
setPage(1);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
// 我的文件列表查询
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["user-files", page, pageSize, debouncedKeyword],
|
||||
queryFn: () => services.upload.listMyUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
})
|
||||
queryKey: ['user-files', page, pageSize, debouncedKeyword],
|
||||
queryFn: () =>
|
||||
services.upload.listMyUploads(
|
||||
page,
|
||||
pageSize,
|
||||
debouncedKeyword || undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const files = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const files = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 上传文件 Mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
return services.upload.uploadFile(file, "generic", undefined, uploadAccessMode)
|
||||
return services.upload.uploadFile(
|
||||
file,
|
||||
'generic',
|
||||
undefined,
|
||||
uploadAccessMode,
|
||||
);
|
||||
},
|
||||
onMutate: () => {
|
||||
setUploading(true)
|
||||
setUploading(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("文件上传成功")
|
||||
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
|
||||
toast.success('文件上传成功');
|
||||
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "上传失败")
|
||||
toast.error(err.message || '上传失败');
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false)
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
// 删除文件 Mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => services.upload.deleteMyFile(id),
|
||||
onSuccess: () => {
|
||||
toast.success("文件已成功删除")
|
||||
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
|
||||
setDeleteTarget(null)
|
||||
toast.success('文件已成功删除');
|
||||
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "删除失败")
|
||||
}
|
||||
})
|
||||
toast.error(err.message || '删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadMutation.mutate(file)
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDownload = (file: UploadRecord) => {
|
||||
const url = services.upload.getDownloadUrl(file.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.file_name
|
||||
a.click()
|
||||
}
|
||||
const url = services.upload.getDownloadUrl(file.id);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.file_name;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className='flex w-full flex-col gap-6'>
|
||||
{/* 操作栏:搜索、权限选择、上传按钮 */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
|
||||
<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" />
|
||||
<div className='flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4'>
|
||||
<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='搜索文件名...'
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
className='pl-9 h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
<div className='flex items-center gap-3 self-end sm:self-auto'>
|
||||
{/* 上传权限设置 */}
|
||||
<div className="flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs">
|
||||
<div className='flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs'>
|
||||
<Button
|
||||
variant={uploadAccessMode === 0 ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs font-normal"
|
||||
variant={uploadAccessMode === 0 ? 'secondary' : 'ghost'}
|
||||
size='sm'
|
||||
className='h-7 px-2.5 text-xs font-normal'
|
||||
onClick={() => setUploadAccessMode(0)}
|
||||
>
|
||||
<Lock className="mr-1 size-3 text-amber-500" />
|
||||
<Lock className='mr-1 size-3 text-amber-500' />
|
||||
私有
|
||||
</Button>
|
||||
<Button
|
||||
variant={uploadAccessMode === 1 ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs font-normal"
|
||||
variant={uploadAccessMode === 1 ? 'secondary' : 'ghost'}
|
||||
size='sm'
|
||||
className='h-7 px-2.5 text-xs font-normal'
|
||||
onClick={() => setUploadAccessMode(1)}
|
||||
>
|
||||
<Globe className="mr-1 size-3 text-sky-500" />
|
||||
<Globe className='mr-1 size-3 text-sky-500' />
|
||||
公开
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
type='file'
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
className='hidden'
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button
|
||||
onClick={triggerUploadClick}
|
||||
disabled={uploading}
|
||||
className="h-9 gap-1.5 text-xs font-medium"
|
||||
className='h-9 gap-1.5 text-xs font-medium'
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
<Plus className="size-3.5" />
|
||||
<Plus className='size-3.5' />
|
||||
)}
|
||||
{uploading ? "正在上传..." : "上传文件"}
|
||||
{uploading ? '正在上传...' : '上传文件'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件展示区 */}
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
<div className='flex items-center justify-center py-24'>
|
||||
<Loader2 className='size-8 animate-spin text-primary' />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<Card className="border border-dashed py-16 flex flex-col items-center justify-center text-center">
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-muted rounded-full text-muted-foreground/60">
|
||||
<Upload className="size-10" />
|
||||
<Card className='border border-dashed py-16 flex flex-col items-center justify-center text-center'>
|
||||
<CardContent className='flex flex-col items-center gap-4'>
|
||||
<div className='p-4 bg-muted rounded-full text-muted-foreground/60'>
|
||||
<Upload className='size-10' />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-sm">还没有上传文件</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
{debouncedKeyword ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='font-semibold text-sm'>还没有上传文件</h3>
|
||||
<p className='text-xs text-muted-foreground max-w-xs'>
|
||||
{debouncedKeyword
|
||||
? '未搜索到匹配的文件'
|
||||
: '上传您的第一个文件,支持图片、视频、文档和压缩包等格式。'}
|
||||
</p>
|
||||
</div>
|
||||
{!debouncedKeyword && (
|
||||
<Button onClick={triggerUploadClick} disabled={uploading} size="sm" className="mt-2 text-xs">
|
||||
<Button
|
||||
onClick={triggerUploadClick}
|
||||
disabled={uploading}
|
||||
size='sm'
|
||||
className='mt-2 text-xs'
|
||||
>
|
||||
选择文件上传
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file.id}
|
||||
className="group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full"
|
||||
className='group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full'
|
||||
>
|
||||
{/* 文件预览/图标区 */}
|
||||
<div className="h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
<div className='h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors'>
|
||||
{file.mime_type.startsWith('image/') ? (
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
quality="medium"
|
||||
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
|
||||
fallbackClassName="min-h-full w-full rounded-md"
|
||||
quality='medium'
|
||||
className='max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103'
|
||||
fallbackClassName='min-h-full w-full rounded-md'
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type)
|
||||
)}
|
||||
|
||||
{/* 访问权限 Badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className='absolute top-2 right-2'>
|
||||
{file.access_mode === 0 ? (
|
||||
<Badge variant="secondary" 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" />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
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' />
|
||||
私有
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" 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" />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
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' />
|
||||
公开
|
||||
</Badge>
|
||||
)}
|
||||
@@ -267,38 +299,48 @@ export function UserFileManager() {
|
||||
</div>
|
||||
|
||||
{/* 文件详情区 */}
|
||||
<CardContent className="p-4 flex-1 flex flex-col justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-xs text-foreground truncate select-all" title={file.file_name}>
|
||||
<CardContent className='p-4 flex-1 flex flex-col justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<h4
|
||||
className='font-semibold text-xs text-foreground truncate select-all'
|
||||
title={file.file_name}
|
||||
>
|
||||
{file.file_name}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<div className='flex items-center justify-between text-[10px] text-muted-foreground font-mono'>
|
||||
<span>{formatFileSize(file.file_size)}</span>
|
||||
<span className="truncate max-w-[120px]" title={file.mime_type}>{file.mime_type}</span>
|
||||
<span
|
||||
className='truncate max-w-[120px]'
|
||||
title={file.mime_type}
|
||||
>
|
||||
{file.mime_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-dashed">
|
||||
<span className="text-[9px] text-muted-foreground/80">{formatDate(file.created_at)}</span>
|
||||
<div className='flex items-center justify-between pt-1 border-t border-dashed'>
|
||||
<span className='text-[9px] text-muted-foreground/80'>
|
||||
{formatDate(file.created_at)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
title="下载文件"
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
title='下载文件'
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
<Download className='size-3.5' />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="删除文件"
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
title='删除文件'
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,57 +352,68 @@ export function UserFileManager() {
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<div className='flex items-center justify-center gap-2 pt-4'>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8 px-3"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='border-dashed text-xs h-8 px-3'
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 size-3.5" />
|
||||
<ChevronLeft className='mr-1 size-3.5' />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground font-medium px-2">
|
||||
<span className='text-xs text-muted-foreground font-medium px-2'>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8 px-3"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='border-dashed text-xs h-8 px-3'
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="ml-1 size-3.5" />
|
||||
<ChevronRight className='ml-1 size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent suppressHydrationWarning>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{" "}
|
||||
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
|
||||
确定要删除文件{' '}
|
||||
<span className='font-semibold text-foreground'>
|
||||
「{deleteTarget?.file_name}」
|
||||
</span>{' '}
|
||||
吗?删除后此文件将无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
)}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user