feat(framework): 回灌 OpenFlare 分层、安全与运行时改进

将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。
邮件头写入前清除 CR/LF,防止 header 注入。
httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。
设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。
升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。
This commit is contained in:
ryan
2026-08-16 11:07:20 +08:00
parent b9b42e3174
commit 6a53619dd2
58 changed files with 2201 additions and 1175 deletions
@@ -43,6 +43,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectContent,
@@ -74,6 +84,9 @@ import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
null,
);
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
@@ -149,6 +162,7 @@ export function EventsTab() {
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
setDeleteTarget(null);
toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
@@ -455,11 +469,7 @@ export function EventsTab() {
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm(t('deleteEventConfirm'))) {
deleteEventMutation.mutate(event.id);
}
}}
onClick={() => setDeleteTarget(event)}
>
<Trash2 className='size-3' />
</Button>
@@ -919,6 +929,35 @@ export function EventsTab() {
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteEventConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteEventMutation.isPending}
onClick={() =>
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
}
>
{deleteEventMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -32,6 +32,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectContent,
@@ -54,6 +64,9 @@ import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
null,
);
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
@@ -96,6 +109,7 @@ export function SettingsTab() {
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
setDeleteTarget(null);
toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
},
@@ -419,15 +433,7 @@ export function SettingsTab() {
variant='ghost'
size='sm'
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (
confirm(
t('deleteChannelConfirm', { name: ch.name }),
)
) {
deleteChannelMutation.mutate(ch.id);
}
}}
onClick={() => setDeleteTarget(ch)}
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
>
<Trash2 className='size-2.5 mr-1' />
@@ -772,6 +778,35 @@ export function SettingsTab() {
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteChannelTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteChannelMutation.isPending}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteChannelMutation.isPending}
onClick={() =>
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
}
>
{deleteChannelMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -37,6 +37,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Breadcrumb,
BreadcrumbItem,
@@ -53,6 +63,8 @@ import { useLocale, useTranslations } from 'next-intl';
import { formatDateTime } from '@/i18n/format';
import type { AppLocale } from '@/i18n/config';
type ConfirmTarget = { id: number; name: string };
export function AccessTokenMain() {
const { user } = useAuth();
const queryClient = useQueryClient();
@@ -67,6 +79,12 @@ export function AccessTokenMain() {
const [copiedId, setCopiedId] = React.useState<number | null>(null);
const [newCreatedToken, setNewCreatedToken] =
React.useState<CreateTokenResponse | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ConfirmTarget | null>(
null,
);
const [rotateTarget, setRotateTarget] = React.useState<ConfirmTarget | null>(
null,
);
// 获取 Token 列表
const accessTokensQuery = useQuery({
@@ -98,6 +116,7 @@ export function AccessTokenMain() {
const deleteTokenMutation = useMutation({
mutationFn: (id: number) => UserService.deleteAccessToken(id),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
@@ -112,6 +131,7 @@ export function AccessTokenMain() {
const rotateTokenMutation = useMutation({
mutationFn: (id: number) => UserService.rotateAccessToken(id),
onSuccess: (data) => {
setRotateTarget(null);
setNewCreatedToken(data);
setViewDialogOpen(true);
void queryClient.invalidateQueries({
@@ -136,18 +156,6 @@ export function AccessTokenMain() {
});
};
const handleDeleteToken = (id: number, name: string) => {
if (window.confirm(ta('deleteConfirm', { name }))) {
deleteTokenMutation.mutate(id);
}
};
const handleRotateToken = (id: number, name: string) => {
if (window.confirm(ta('rotateConfirm', { name }))) {
rotateTokenMutation.mutate(id);
}
};
const handleCopyText = async (text: string, id: number) => {
try {
await navigator.clipboard.writeText(text);
@@ -297,7 +305,9 @@ export function AccessTokenMain() {
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)}
onClick={() =>
setRotateTarget({ id: token.id, name: token.name })
}
disabled={rotateTokenMutation.isPending}
>
<RefreshCw
@@ -310,7 +320,9 @@ export function AccessTokenMain() {
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)}
onClick={() =>
setDeleteTarget({ id: token.id, name: token.name })
}
disabled={deleteTokenMutation.isPending}
>
<Trash2 className='size-3.5 mr-1' />
@@ -482,6 +494,64 @@ export function AccessTokenMain() {
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{ta('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{ta('deleteConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTokenMutation.isPending}
onClick={() =>
deleteTarget && deleteTokenMutation.mutate(deleteTarget.id)
}
>
{deleteTokenMutation.isPending
? ta('revoking')
: ta('confirmRevoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(rotateTarget)}
onOpenChange={(open) => !open && setRotateTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{ta('rotateConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{ta('rotateConfirm', { name: rotateTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rotateTokenMutation.isPending}>
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={rotateTokenMutation.isPending}
onClick={() =>
rotateTarget && rotateTokenMutation.mutate(rotateTarget.id)
}
>
{rotateTokenMutation.isPending
? ta('rotating')
: ta('confirmRotate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}
@@ -40,6 +40,16 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AuthSourceModal } from '@/components/common/settings/auth-source-modal';
import services from '@/lib/services';
import type { AuthSource, SystemConfig } from '@/lib/services/admin';
@@ -93,8 +103,10 @@ interface SecurityTabProps {
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.security');
const tCommon = useTranslations('common');
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AuthSource | null>(null);
const [capCount, setCapCount] = useState('');
const [capDifficulty, setCapDifficulty] = useState('');
@@ -233,6 +245,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await services.adminAuthSource.deleteAuthSource(sourceId);
},
onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
@@ -490,17 +503,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
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(
t('deleteAuthSourceConfirm', {
name: source.display_name || source.name,
}),
)
) {
deleteSourceMutation.mutate(source.id);
}
}}
onClick={() => setDeleteTarget(source)}
>
<Trash2 className='size-4' />
</Button>
@@ -706,6 +709,37 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await authSourcesQuery.refetch();
}}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteAuthSourceTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteAuthSourceConfirm', {
name: deleteTarget?.display_name || deleteTarget?.name || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSourceMutation.isPending}>
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteSourceMutation.isPending}
onClick={() =>
deleteTarget && deleteSourceMutation.mutate(deleteTarget.id)
}
>
{deleteSourceMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -23,6 +23,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import services from '@/lib/services';
import type { Template } from '@/lib/services/admin/types';
import { toast } from 'sonner';
@@ -35,6 +45,7 @@ export function TemplatesManager() {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
);
const [deleteTarget, setDeleteTarget] = useState<Template | null>(null);
// Form states
const [key, setKey] = useState('');
@@ -95,6 +106,7 @@ export function TemplatesManager() {
await services.adminTemplate.deleteTemplate(key);
},
onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success(t('templateDeleted'));
},
@@ -225,15 +237,7 @@ export function TemplatesManager() {
disabled={
tmpl.is_system || deleteTemplateMutation.isPending
}
onClick={() => {
if (
window.confirm(
t('deleteTemplateConfirm', { name: tmpl.name }),
)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
}}
onClick={() => setDeleteTarget(tmpl)}
>
<Trash2 className='size-4' />
</Button>
@@ -401,6 +405,35 @@ export function TemplatesManager() {
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTemplateTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteTemplateConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTemplateMutation.isPending}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteTemplateMutation.isPending}
onClick={() =>
deleteTarget && deleteTemplateMutation.mutate(deleteTarget.key)
}
>
{deleteTemplateMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+24 -1
View File
@@ -5,7 +5,7 @@ import axios, {
InternalAxiosRequestConfig,
} from 'axios';
import { toast } from 'sonner';
import { apiConfig } from './config';
import { apiConfig, getApiBaseUrl } from './config';
import {
ApiErrorBase,
ForbiddenError,
@@ -26,11 +26,28 @@ const apiClient = axios.create({
baseURL: apiConfig.baseURL,
timeout: apiConfig.timeout,
withCredentials: apiConfig.withCredentials,
// Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes.
maxRedirects: 5,
// Gin QueryArray("hosts") expects hosts=a&hosts=b, not hosts[]=a.
paramsSerializer: {
indexes: null,
},
headers: {
'Content-Type': 'application/json',
},
});
/** Normalize legacy /api/* paths: strip trailing slash to avoid 308/301 loops in dev proxy. */
function normalizeApiPath(url?: string): string | undefined {
if (!url || !url.startsWith('/api/')) {
return url;
}
if (url.length > 5 && url.endsWith('/')) {
return url.replace(/\/+$/, '');
}
return url;
}
/**
* 请求取消令牌存储
*/
@@ -73,6 +90,12 @@ function getRequestKey(config: {
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite.
if (typeof window !== 'undefined') {
config.baseURL = getApiBaseUrl();
}
config.url = normalizeApiPath(config.url);
const requestKey = getRequestKey(config);
const source = axios.CancelToken.source();
config.cancelToken = source.token;
+22 -4
View File
@@ -245,6 +245,12 @@
"rotateFailed": "Failed to rotate access token",
"deleteConfirm": "Revoke token \u201c{name}\u201d? It will stop working immediately and cannot be recovered.",
"rotateConfirm": "Rotate the secret for token \u201c{name}\u201d? A new secret will be issued and the old one will stop working immediately.",
"deleteConfirmTitle": "Confirm token revocation",
"rotateConfirmTitle": "Confirm token rotation",
"confirmRevoke": "Confirm revoke",
"confirmRotate": "Confirm rotate",
"revoking": "Revoking...",
"rotating": "Rotating...",
"subtitle": "Manage API access secrets for development or third-party tools",
"adminBadge": "Admin",
"adminPermission": "Admin permission",
@@ -374,7 +380,10 @@
"type": "Type",
"secretConfigured": "Secret configured",
"secretNotConfigured": "Secret not configured",
"deleteAuthSourceConfirm": "Delete authentication source \u201c{name}\u201d?",
"deleteAuthSourceConfirm": "Delete authentication source \u201c{name}\u201d? This cannot be undone.",
"deleteAuthSourceTitle": "Confirm delete authentication source",
"confirmDelete": "Confirm delete",
"deleting": "Deleting...",
"noAuthSources": "No authentication sources configured. Click the button above to add one.",
"captchaConfig": "CAPTCHA configuration (Cap CAPTCHA)",
"captchaConfigDesc": "Configure Proof-of-Work (PoW) based invisible CAPTCHA to protect login from brute-force and credential-stuffing attacks",
@@ -463,7 +472,10 @@
"custom": "Custom",
"identifier": "Identifier",
"subject": "Subject",
"deleteTemplateConfirm": "Delete template \u201c{name}\u201d?",
"deleteTemplateConfirm": "Delete template \u201c{name}\u201d? This cannot be undone.",
"deleteTemplateTitle": "Confirm delete template",
"confirmDelete": "Confirm delete",
"deleting": "Deleting...",
"noTemplates": "No notification templates configured. Click the button above to add one.",
"editTemplate": "Edit notification template",
"addTemplateTitle": "Add notification template",
@@ -876,7 +888,10 @@
"noChannelSpecified": "No channel specified",
"emailChannel": "Email",
"configure": "Configure",
"deleteEventConfirm": "Delete this notification event configuration?",
"deleteEventConfirm": "Delete this notification event configuration? This cannot be undone.",
"deleteEventTitle": "Confirm delete notification event",
"confirmDelete": "Confirm delete",
"deleting": "Deleting...",
"delete": "Delete",
"addEventDialogTitle": "Add notification event",
"addEventDialogDesc": "Select a system event and bind push channels. It will be active immediately after creation.",
@@ -944,7 +959,10 @@
"noRemark": "No remark",
"test": "Test",
"edit": "Edit",
"deleteChannelConfirm": "Delete channel \"{name}\"?",
"deleteChannelConfirm": "Delete channel \"{name}\"? This cannot be undone.",
"deleteChannelTitle": "Confirm delete channel",
"confirmDelete": "Confirm delete",
"deleting": "Deleting...",
"delete": "Delete",
"editChannelDialogTitle": "Edit channel",
"createChannelDialogTitle": "Create channel",
+22 -4
View File
@@ -245,6 +245,12 @@
"rotateFailed": "轮换访问令牌失败",
"deleteConfirm": "确定要删除并撤销令牌「{name}」吗?删除后此令牌将立即失效且不可恢复。",
"rotateConfirm": "确定要轮换令牌「{name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。",
"deleteConfirmTitle": "确认撤销访问令牌",
"rotateConfirmTitle": "确认轮换访问令牌",
"confirmRevoke": "确认撤销",
"confirmRotate": "确认轮换",
"revoking": "撤销中...",
"rotating": "轮换中...",
"subtitle": "管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API",
"adminBadge": "管理员",
"adminPermission": "管理员权限",
@@ -374,7 +380,10 @@
"type": "类型",
"secretConfigured": "Secret 已配置",
"secretNotConfigured": "Secret 未配置",
"deleteAuthSourceConfirm": "确定删除认证源「{name}」吗?",
"deleteAuthSourceConfirm": "确定删除认证源「{name}」吗?删除后无法恢复。",
"deleteAuthSourceTitle": "确认删除认证源",
"confirmDelete": "确认删除",
"deleting": "删除中...",
"noAuthSources": "暂无配置的认证源,点击上方按钮新增",
"captchaConfig": "人机验证配置 (Cap CAPTCHA)",
"captchaConfigDesc": "配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击",
@@ -463,7 +472,10 @@
"custom": "自定义",
"identifier": "标识符",
"subject": "主题",
"deleteTemplateConfirm": "确定删除模板「{name}」吗?",
"deleteTemplateConfirm": "确定删除模板「{name}」吗?删除后无法恢复。",
"deleteTemplateTitle": "确认删除模板",
"confirmDelete": "确认删除",
"deleting": "删除中...",
"noTemplates": "暂无配置的通知模板,点击上方按钮新增",
"editTemplate": "编辑通知模板",
"addTemplateTitle": "新增通知模板",
@@ -876,7 +888,10 @@
"noChannelSpecified": "未指定渠道",
"emailChannel": "邮件",
"configure": "配置",
"deleteEventConfirm": "确定要删除该通知事件配置吗?",
"deleteEventConfirm": "确定要删除该通知事件配置吗?删除后无法恢复。",
"deleteEventTitle": "确认删除通知事件",
"confirmDelete": "确认删除",
"deleting": "删除中...",
"delete": "删除",
"addEventDialogTitle": "新增通知事件",
"addEventDialogDesc": "选择系统内置的事件并绑定推送渠道,创建后即可开始生效",
@@ -944,7 +959,10 @@
"noRemark": "无备注",
"test": "测试",
"edit": "编辑",
"deleteChannelConfirm": "确定要删除通道 \"{name}\" 吗?",
"deleteChannelConfirm": "确定要删除通道「{name}」吗?删除后无法恢复。",
"deleteChannelTitle": "确认删除通道",
"confirmDelete": "确认删除",
"deleting": "删除中...",
"delete": "删除",
"editChannelDialogTitle": "编辑消息通道",
"createChannelDialogTitle": "新建消息通道",
+2
View File
@@ -7,6 +7,8 @@ const isExport = process.env.NEXT_STANDALONE_EXPORT === 'true';
const nextConfig: NextConfig = {
reactCompiler: true,
// Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is.
skipTrailingSlashRedirect: true,
experimental: {},
...(isExport
? { output: 'export' }