This commit is contained in:
rustmailer
2026-05-07 11:56:08 +08:00
parent 2802c7ea07
commit 7eacfbfb20
27 changed files with 465 additions and 353 deletions
@@ -0,0 +1,159 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
import * as React from 'react'
import { Mail, Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useAccountContext } from '../context'
export type AddAccountType = 'IMAP' | 'NoSync'
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddAccountDialog({
open,
onOpenChange
}: Props) {
const { t } = useTranslation()
const { setOpen } = useAccountContext()
const [value, setValue] = React.useState<AddAccountType>('IMAP')
function handleContinue() {
if (value === 'IMAP') {
setOpen('add-imap')
} else {
setOpen('add-nosync')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader className="text-left">
<DialogTitle>
{t('accounts.add')}
</DialogTitle>
<DialogDescription>
{t('accounts.selectAccountType')}
</DialogDescription>
</DialogHeader>
<RadioGroup
value={value}
onValueChange={(v) => setValue(v as AddAccountType)}
className="space-y-4 py-2"
>
<Label
htmlFor="imap-account"
className={cn(
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
value === 'IMAP'
? 'border-primary bg-muted/50'
: 'hover:bg-muted/30'
)}
>
<RadioGroupItem
value="IMAP"
id="imap-account"
className="mt-1"
/>
<div className="flex flex-1 gap-4">
<div className="rounded-xl border p-2">
<Mail className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="font-medium">
{t('accounts.imapAccount')}
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.imapAccountDescription')}
</div>
</div>
</div>
</Label>
<Label
htmlFor="nosync-account"
className={cn(
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
value === 'NoSync'
? 'border-primary bg-muted/50'
: 'hover:bg-muted/30'
)}
>
<RadioGroupItem
value="NoSync"
id="nosync-account"
className="mt-1"
/>
<div className="flex flex-1 gap-4">
<div className="rounded-xl border p-2">
<Database className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="font-medium">
{t('accounts.noSyncAccount')}
</div>
<div className="text-sm text-muted-foreground">
{t('accounts.noSyncAccountDescription')}
</div>
</div>
</div>
</Label>
</RadioGroup>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleContinue}>
{t('accounts.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -38,7 +38,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion"
import {
CheckCircle,
Clock,
Loader2,
Activity,
@@ -55,7 +54,6 @@ interface Props {
}
function StatusBadge({ status }: { status: string }) {
// 保持颜色逻辑,但在暗色模式下这些颜色也相对友好,如果需要完全适配可调整为 bg-primary/10 等
const map: Record<string, string> = {
Running: 'bg-blue-500/10 text-blue-600',
Downloading: 'bg-blue-500/10 text-blue-600',
@@ -141,7 +139,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const session = state?.active_session
const history = state?.history || []
const globalErrors = state?.global_errors || []
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -168,10 +165,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
{t('accounts.runningState.tabs.history')}
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold">{history.length}</Badge>
</TabsTrigger>
<TabsTrigger value="errors" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
{t('accounts.runningState.tabs.global_errors')}
{globalErrors.length > 0 && <Badge variant="destructive" className="ml-2 h-4 px-1 text-[10px] font-bold">{globalErrors.length}</Badge>}
</TabsTrigger>
</TabsList>
</div>
@@ -395,82 +388,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="errors" className="h-full m-0 data-[state=active]:flex flex-col">
<ScrollArea className="flex-1">
<div className="p-4 sm:p-6">
{globalErrors.length === 0 ? (
<div className="py-32 text-center text-muted-foreground">
<CheckCircle className="w-12 h-12 mx-auto mb-2 opacity-20" />
<p className="font-medium italic text-sm">{t('accounts.runningState.empty.no_global_errors')}</p>
</div>
) : (
<div className="relative">
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-destructive/20" />
<div className="space-y-6">
{[...globalErrors]
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.map((e, i) => (
<div key={i} className="relative pl-10 min-w-0">
<div className="absolute left-0 top-1.5 w-[40px] flex justify-center">
{i === 0 ? (
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-destructive"></span>
</span>
) : (
<div className="w-2.5 h-2.5 rounded-full bg-destructive/20 mt-0.5" />
)}
</div>
<div
className={`
p-4 border rounded-2xl shadow-sm transition-all min-w-0
${i === 0
? 'border-destructive/20 bg-destructive/5 ring-1 ring-destructive/10'
: 'border-border bg-card'}
`}
>
<div className="flex items-start justify-between gap-3 mb-2 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
{i === 0 && (
<Badge className="bg-destructive hover:bg-destructive text-[9px] h-4 px-1">
{t('accounts.runningState.latest')}
</Badge>
)}
<div className="text-[10px] font-mono font-bold text-destructive bg-destructive/10 px-1.5 py-0.5 rounded break-all">
<span className="sm:hidden">
{format(new Date(e.at), 'HH:mm')}
</span>
<span className="hidden sm:inline">
{format(new Date(e.at), 'yyyy-MM-dd HH:mm:ss')}
</span>
</div>
</div>
<AlertTriangle
className={`w-4 h-4 shrink-0 ${i === 0 ? 'text-destructive' : 'text-destructive/50'
}`}
/>
</div>
<div className="min-w-0">
<p
className={`
text-xs font-bold leading-relaxed whitespace-pre-wrap break-all min-w-0
${i === 0 ? 'text-foreground' : 'text-muted-foreground'}
`}
>
{e.error}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</>
)}
</div>
@@ -21,6 +21,7 @@ import { AccountModel } from '@/api/account/api';
import React from 'react'
export type AccountDialogType =
| 'add'
| 'add-imap'
| 'add-nosync'
| 'edit-imap'
+9 -22
View File
@@ -43,6 +43,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useTranslation } from 'react-i18next'
import { AccountAccessAssignmentDialog } from './components/access-assignment-dialog'
import { useCurrentUser } from '@/hooks/use-current-user'
import { AddAccountDialog } from './components/add-account-dialog'
export default function Accounts() {
const { t } = useTranslation()
@@ -75,29 +76,12 @@ export default function Accounts() {
{require_any_permission(['system:root', 'account:create']) && <div className="flex gap-2">
<div className="flex rounded-md shadow-sm">
<Button
onClick={() => setOpen("add-imap")}
onClick={() => setOpen("add")}
className="rounded-r-none border-r-0"
>
<Plus className="h-4 w-4" />
{t('accounts.addImap')}
{t('accounts.add')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="h-9 w-9 rounded-l-none border-l-0"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{t('accounts.moreAccountTypes')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
<Plus className="h-4 w-4" />
{t('accounts.addNoSync')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>}
</div>
@@ -120,8 +104,8 @@ export default function Accounts() {
{t('accounts.noAccountConfigurationsDesc')}
</p>
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
{t('accounts.addConfiguration')}
<Button variant="default" className="w-64" onClick={() => setOpen("add")}>
{t('accounts.add')}
</Button>
</div>
</div>
@@ -130,7 +114,10 @@ export default function Accounts() {
</div>
</div>
</Main>
<AddAccountDialog
key='account-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')} />
<AccountActionDialog
key='imap-account-add'