feat(mail): 重构邮件详情元信息展示
CI and Docker / Check web and api (push) Waiting to run
CI and Docker / Build and publish all-in-one (push) Blocked by required conditions
CI and Docker / Build and publish api (push) Blocked by required conditions
CI and Docker / Build and publish web (push) Blocked by required conditions
CI and Docker / Build and publish dovecot (push) Blocked by required conditions
CI and Docker / Build and publish postfix (push) Blocked by required conditions
CI and Docker / Build and publish rspamd (push) Blocked by required conditions
CI and Docker / Check web and api (push) Waiting to run
CI and Docker / Build and publish all-in-one (push) Blocked by required conditions
CI and Docker / Build and publish api (push) Blocked by required conditions
CI and Docker / Build and publish web (push) Blocked by required conditions
CI and Docker / Build and publish dovecot (push) Blocked by required conditions
CI and Docker / Build and publish postfix (push) Blocked by required conditions
CI and Docker / Build and publish rspamd (push) Blocked by required conditions
- 抽离 `MessageMetaPanel` 统一展示发件人、收件人、抄送、密送和时间信息。 - 补充地址解析与列表渲染,提升详情页信息完整度和可读性。
This commit is contained in:
+100
-11
@@ -726,7 +726,7 @@ export function MailPage() {
|
|||||||
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>
|
<Button variant="destructive" size="sm" onClick={() => confirmDeleteMessage(selected)}>删除</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</span> 发给 {selected.to.join(", ")} · {formatDateTime(selected.receivedAt)}</div>
|
<MessageMetaPanel message={selected} />
|
||||||
<MessageLabels
|
<MessageLabels
|
||||||
messageLabels={selected.labels || []}
|
messageLabels={selected.labels || []}
|
||||||
availableLabels={labelItems}
|
availableLabels={labelItems}
|
||||||
@@ -1228,16 +1228,7 @@ function CompactMessageDetail({
|
|||||||
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
<Star className={cn("h-5 w-5", selected.isStarred && "fill-yellow-400 text-yellow-500")} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<MessageMetaPanel message={selected} />
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
|
||||||
<Avatar className="size-10 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(senderDisplayName(selected), selected.from)}</AvatarFallback></Avatar>
|
|
||||||
<div className="min-w-0 text-sm">
|
|
||||||
<div className="truncate font-medium text-foreground" title={senderTitle(selected)}>{senderDisplayName(selected)}</div>
|
|
||||||
<div className="truncate text-muted-foreground">收件人 {selected.to.join(", ")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-left text-sm text-muted-foreground sm:text-right">{formatDateTime(selected.receivedAt)}</div>
|
|
||||||
</div>
|
|
||||||
<MessageLabels
|
<MessageLabels
|
||||||
messageLabels={selected.labels || []}
|
messageLabels={selected.labels || []}
|
||||||
availableLabels={labels}
|
availableLabels={labels}
|
||||||
@@ -1428,6 +1419,104 @@ function senderTitle(message: MailMessage) {
|
|||||||
return name ? `${name} <${from}>` : from
|
return name ? `${name} <${from}>` : from
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function senderAddress(message: MailMessage) {
|
||||||
|
return extractAddress(message.from) || decodeMimeHeader(message.fromName?.trim() || "") || "未知发件人地址"
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAddress(value?: string) {
|
||||||
|
const text = decodeMimeHeader((value || "").trim())
|
||||||
|
if (!text) return ""
|
||||||
|
const bracketAddress = text.match(/<([^>]+)>/)?.[1]?.trim()
|
||||||
|
return bracketAddress || text
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanAddressList(items?: string[]) {
|
||||||
|
return (items || []).map((item) => extractAddress(item)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameMailTime(a?: string, b?: string) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const left = new Date(a).getTime()
|
||||||
|
const right = new Date(b).getTime()
|
||||||
|
return !Number.isNaN(left) && !Number.isNaN(right) && left === right
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageMetaPanel({ message }: { message: MailMessage }) {
|
||||||
|
const fromName = senderDisplayName(message)
|
||||||
|
const fromAddress = senderAddress(message)
|
||||||
|
const to = cleanAddressList(message.to)
|
||||||
|
const cc = cleanAddressList(message.cc)
|
||||||
|
const bcc = cleanAddressList(message.bcc)
|
||||||
|
const deliveredTo = extractAddress(message.recipientAddress || message.mailboxAddress || "")
|
||||||
|
const showSentAt = Boolean(message.sentAt) && !sameMailTime(message.sentAt, message.receivedAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-xl border bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Avatar className="size-10 shrink-0 rounded-full">
|
||||||
|
<AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(fromName, fromAddress)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<MessageMetaRow label="发件人">
|
||||||
|
<span className="break-words font-medium text-foreground" title={senderTitle(message)}>{fromName}</span>
|
||||||
|
</MessageMetaRow>
|
||||||
|
<MessageMetaRow label="发件人地址">
|
||||||
|
<span className="break-all">{fromAddress}</span>
|
||||||
|
</MessageMetaRow>
|
||||||
|
<MessageMetaRow label="收件人">
|
||||||
|
<AddressList values={to} empty="未填写收件人" />
|
||||||
|
</MessageMetaRow>
|
||||||
|
{cc.length > 0 && (
|
||||||
|
<MessageMetaRow label="抄送">
|
||||||
|
<AddressList values={cc} />
|
||||||
|
</MessageMetaRow>
|
||||||
|
)}
|
||||||
|
{bcc.length > 0 && (
|
||||||
|
<MessageMetaRow label="密送">
|
||||||
|
<AddressList values={bcc} />
|
||||||
|
</MessageMetaRow>
|
||||||
|
)}
|
||||||
|
{deliveredTo && (
|
||||||
|
<MessageMetaRow label="投递邮箱">
|
||||||
|
<span className="break-all">{deliveredTo}</span>
|
||||||
|
</MessageMetaRow>
|
||||||
|
)}
|
||||||
|
{showSentAt && (
|
||||||
|
<MessageMetaRow label="发送时间">
|
||||||
|
<span>{formatDateTime(message.sentAt)}</span>
|
||||||
|
</MessageMetaRow>
|
||||||
|
)}
|
||||||
|
<MessageMetaRow label="接收时间">
|
||||||
|
<span>{formatDateTime(message.receivedAt)}</span>
|
||||||
|
</MessageMetaRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageMetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)]">
|
||||||
|
<div className="shrink-0 text-xs font-medium text-muted-foreground sm:pt-0.5">{label}</div>
|
||||||
|
<div className="min-w-0 text-foreground">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressList({ values, empty = "无" }: { values: string[]; empty?: string }) {
|
||||||
|
if (values.length === 0) return <span className="text-muted-foreground">{empty}</span>
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<span key={`${value}-${index}`} className="inline-flex max-w-full rounded-md bg-background px-2 py-0.5 text-xs text-foreground ring-1 ring-border">
|
||||||
|
<span className="truncate" title={value}>{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MessageRow({
|
function MessageRow({
|
||||||
message,
|
message,
|
||||||
active,
|
active,
|
||||||
|
|||||||
Reference in New Issue
Block a user