chore: commit all local changes after local verification

This commit is contained in:
Haitao Pan 2026-02-05 17:05:43 +08:00
parent b2f1c8a9b9
commit f6a8f6ea41
43 changed files with 28 additions and 2200 deletions

View File

@ -1,24 +1,7 @@
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
export const Workshop = defineDocumentType(() => ({
name: 'Workshop',
filePathPattern: '**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
summary: { type: 'string', required: true },
level: { type: 'string', default: 'Intro' },
duration: { type: 'string', required: false },
tags: { type: 'list', of: { type: 'string' }, default: [] },
updatedAt: { type: 'date', required: false },
},
computedFields: {
slug: { type: 'string', resolve: (doc) => doc._raw.flattenedPath },
url: { type: 'string', resolve: (doc) => `/workshop/${doc._raw.flattenedPath}` },
},
}))
import { makeSource } from 'contentlayer/source-files'
export default makeSource({
contentDirPath: 'src/content/workshop',
documentTypes: [Workshop],
contentDirPath: 'src/content',
documentTypes: [],
})

View File

@ -1,14 +0,0 @@
export const dynamic = 'error'
import ComposeForm from '../../../../components/mail/ComposeForm'
type PageProps = {
params: Promise<{
slug: string
}>
}
export default async function ComposePage({ params }: PageProps) {
const { slug: tenantId } = await params
return <ComposeForm tenantId={tenantId} />
}

View File

@ -1,20 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import MessageView from '../../../../../components/mail/MessageView'
export default function MessageDetailPage({ params }: { params: { slug: string; id: string } }) {
const router = useRouter()
const tenantId = params.slug
return (
<div className="flex flex-col gap-4">
<MessageView
tenantId={tenantId}
messageId={params.id}
showBackButton
onBack={() => router.back()}
/>
</div>
)
}

View File

@ -1,14 +0,0 @@
export const dynamic = 'error'
import MailDashboard from '../../../components/mail/MailDashboard'
type PageProps = {
params: Promise<{
slug: string
}>
}
export default async function TenantMailPage({ params }: PageProps) {
const { slug: tenantId } = await params
return <MailDashboard tenantId={tenantId} tenantName={tenantId} />
}

View File

@ -1,14 +0,0 @@
export const dynamic = 'error'
import MailSettings from '../../../../components/mail/MailSettings'
type PageProps = {
params: Promise<{
slug: string
}>
}
export default async function MailSettingsPage({ params }: PageProps) {
const { slug: tenantId } = await params
return <MailSettings tenantId={tenantId} />
}

View File

@ -1,15 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId: string }
const message = getMessage(tenantId, body.messageId)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const labels = Array.from(new Set([...message.labels, 'AI-Reviewed']))
return NextResponse.json({ labels })
}

View File

@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId: string; style?: string; language?: string }
const message = body?.messageId ? getMessage(tenantId, body.messageId) : null
const base = message?.aiInsights?.suggestions ?? [
'收到,我们将安排同事跟进。',
'感谢提醒,我们将及时回复。',
'请告知是否需要更多信息。',
]
return NextResponse.json({ suggestions: base })
}

View File

@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function POST(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const body = (await request.json()) as { messageId?: string; raw?: string }
if (!body.messageId && !body.raw) {
return NextResponse.json({ error: 'messageId or raw is required' }, { status: 400 })
}
if (body.messageId) {
const message = getMessage(tenantId, body.messageId)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (message.aiInsights) {
return NextResponse.json(message.aiInsights)
}
}
return NextResponse.json({
summary: '示例摘要:邮件内容将提炼为关键句子。',
bullets: ['示例要点一', '示例要点二'],
actions: ['示例行动一'],
tone: '信息',
})
}

View File

@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getInbox, resolveTenantId } from '../mockData'
export async function GET(request: NextRequest) {
const tenantHeader = request.headers.get('x-tenant-id')
const tenantQuery = request.nextUrl.searchParams.get('tenantId')
const tenantId = resolveTenantId(tenantHeader ?? tenantQuery)
const inbox = getInbox(tenantId)
const label = request.nextUrl.searchParams.get('label')
const query = request.nextUrl.searchParams.get('q')?.toLowerCase().trim()
let filtered = inbox.messages
if (label === 'unread') {
filtered = filtered.filter((item) => item.unread)
} else if (label === 'starred') {
filtered = filtered.filter((item) => item.starred)
} else if (label && label !== 'important') {
filtered = filtered.filter((item) => item.labels.includes(label))
}
if (query) {
filtered = filtered.filter((item) =>
[item.subject, item.snippet, item.from.email, item.from.name]
.filter(Boolean)
.some((field) => field!.toLowerCase().includes(query)),
)
}
return NextResponse.json({
...inbox,
messages: filtered,
})
}

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMessage, resolveTenantId } from '../../mockData'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const { id } = await params
const message = getMessage(tenantId, id)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(message)
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const { id } = await params
const message = getMessage(tenantId, id)
if (!message) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@ -1,208 +0,0 @@
import type { MailInboxResponse, MailListMessage, MailMessageDetail, NamespacePolicy } from '@lib/mail/types'
type TenantMailData = {
inbox: MailListMessage[]
messages: Record<string, MailMessageDetail>
namespace: NamespacePolicy
}
const now = Date.now()
const baseMessages: MailListMessage[] = [
{
id: 'msg-1001',
subject: '【故障通报】核心链路延迟恢复通知',
snippet: '生产集群延迟已恢复至正常指标,详见行动项。',
from: { name: 'SRE 值班', email: 'sre@svc.plus' },
to: [{ name: 'Ops 团队', email: 'ops@tenant.io' }],
date: new Date(now - 5 * 60 * 1000).toISOString(),
unread: true,
starred: true,
labels: ['Incident', 'Priority'],
hasAttachments: true,
aiSummary: {
preview: '延迟恢复,需确认追踪指标。',
tone: '紧急',
},
},
{
id: 'msg-1002',
subject: '月度账单与消耗对账单',
snippet: '附件包含 5 月份资源使用与费用明细,请于本周内确认。',
from: { name: 'Finance Robot', email: 'billing@svc.plus' },
to: [{ name: 'Finance', email: 'finance@tenant.io' }],
date: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
unread: false,
labels: ['Billing'],
hasAttachments: true,
aiSummary: {
preview: '账单结算提醒,需核对折扣。',
tone: '正式',
},
},
{
id: 'msg-1003',
subject: 'AI 助手联调会议记录',
snippet: '会议纪要包含下一步联调行动项与 SLA 讨论。',
from: { name: '产品经理', email: 'pm@svc.plus' },
to: [{ name: 'AI 团队', email: 'ai@tenant.io' }],
date: new Date(now - 5 * 60 * 60 * 1000).toISOString(),
unread: false,
labels: ['Product'],
aiSummary: {
preview: '提炼三条关键任务。',
tone: '合作',
},
},
{
id: 'msg-1004',
subject: '【提醒】IAM 权限矩阵变更审批',
snippet: '审批单待确认,涉及新的只读角色授权,请于 24 小时内处理。',
from: { name: 'Access Bot', email: 'iam@svc.plus' },
to: [{ name: 'Security', email: 'sec@tenant.io' }],
date: new Date(now - 12 * 60 * 60 * 1000).toISOString(),
unread: true,
labels: ['Security'],
aiSummary: {
preview: '审批截止前需确认。',
tone: '提醒',
},
},
]
const detailMap: Record<string, MailMessageDetail> = {
'msg-1001': {
...baseMessages[0],
text: '生产链路延迟恢复。请确认后续监控指标与复盘会议安排。',
html: '<p>生产链路延迟已恢复。</p><ul><li>核对 Prometheus 延迟指标</li><li>更新状态页面</li><li>准备 18:00 复盘会议</li></ul>',
attachments: [
{
id: 'att-1',
fileName: 'incident-report.pdf',
contentType: 'application/pdf',
size: 234567,
downloadUrl: '#',
},
],
aiInsights: {
summary: '生产链路延迟恢复,需跟进指标及复盘会议。',
bullets: ['Prometheus 延迟恢复', '状态页面需更新', '18:00 复盘会议'],
actions: ['确认状态页', '同步客户邮件', '准备复盘材料'],
tone: '紧急',
suggestions: [
'感谢通知,已安排团队核查 Prometheus 指标。',
'收到,我们将于 18:00 准备复盘材料。',
'请同步可能影响的客户列表,方便统一公告。',
],
},
},
'msg-1002': {
...baseMessages[1],
text: '随信附上 5 月份账单,包含折扣与超额费用明细,请在本周内完成对账。',
attachments: [
{
id: 'att-2',
fileName: 'may-usage.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 54567,
downloadUrl: '#',
},
],
aiInsights: {
summary: '账单需要财务团队在本周内确认。',
bullets: ['包含折扣明细', '有部分资源超额', '需在周五前回复'],
actions: ['核对折扣', '确认超额原因', '回邮确认'],
tone: '正式',
suggestions: ['已收悉,我们将在周四前完成对账并回复。'],
},
},
'msg-1003': {
...baseMessages[2],
html: '<p>联调会议要点:</p><ol><li>六月上线 Beta需补充监控指标</li><li>AI 模型回退策略需评审</li><li>下一次联调会议安排在周五上午</li></ol>',
aiInsights: {
summary: '会议聚焦上线计划、模型回退与下次会议时间。',
bullets: ['六月 Beta 上线', '确认模型回退策略', '周五上午继续联调'],
actions: ['同步监控指标清单', '准备回退方案文档', '发送会议邀请'],
tone: '合作',
},
},
'msg-1004': {
...baseMessages[3],
text: 'IAM 角色矩阵变更涉及新建只读角色,需要安全团队审批。',
aiInsights: {
summary: '安全团队需在 24 小时内确认新角色审批。',
bullets: ['新增只读角色', '审批截止 24 小时内', '需评估权限边界'],
actions: ['审阅角色权限', '评估风险', '确认审批或驳回'],
tone: '提醒',
},
},
}
const TENANT_DATA: Record<string, TenantMailData> = {
'tenant-alpha': {
inbox: baseMessages,
messages: detailMap,
namespace: {
model: 'gpt-4o-mini',
temperature: 0.3,
maxTokens: 2048,
rateLimitPerMinute: 60,
vectorIndex: 's3://tenant-alpha-mail',
policy: '{"blockedKeywords": ["NDA", "秘密"]}',
updatedAt: new Date(now - 3600 * 1000).toISOString(),
},
},
default: {
inbox: baseMessages,
messages: detailMap,
namespace: {
model: 'gpt-4o-mini',
temperature: 0.5,
maxTokens: 2048,
rateLimitPerMinute: 30,
vectorIndex: 's3://default-mail',
policy: '{"allowExternal": true}',
updatedAt: new Date(now - 7200 * 1000).toISOString(),
},
},
}
export function resolveTenantId(raw: string | null | undefined) {
if (!raw) {
return 'default'
}
return TENANT_DATA[raw] ? raw : 'default'
}
export function getInbox(tenantId: string): MailInboxResponse {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return {
messages: data.inbox,
labels: [
{ id: 'Incident', name: 'Incident', color: '#f97316', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Incident')).length },
{ id: 'Billing', name: 'Billing', color: '#2563eb', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Billing')).length },
{ id: 'Security', name: 'Security', color: '#7c3aed', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Security')).length },
{ id: 'Product', name: 'Product', color: '#0f766e', unread: data.inbox.filter((item) => item.unread && item.labels.includes('Product')).length },
],
unreadCount: data.inbox.filter((item) => item.unread).length,
nextCursor: null,
}
}
export function getMessage(tenantId: string, id: string): MailMessageDetail | null {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return data.messages[id] ?? null
}
export function getNamespace(tenantId: string): NamespacePolicy {
const data = TENANT_DATA[tenantId] ?? TENANT_DATA.default
return data.namespace
}
export function updateNamespace(tenantId: string, patch: Partial<NamespacePolicy>): NamespacePolicy {
const key = TENANT_DATA[tenantId] ? tenantId : 'default'
const current = TENANT_DATA[key].namespace
const next = { ...current, ...patch, updatedAt: new Date().toISOString() }
TENANT_DATA[key].namespace = next
return next
}

View File

@ -1,14 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getNamespace, resolveTenantId, updateNamespace } from '../mockData'
export async function GET(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
return NextResponse.json(getNamespace(tenantId))
}
export async function PUT(request: NextRequest) {
const tenantId = resolveTenantId(request.headers.get('x-tenant-id'))
const patch = (await request.json()) as Record<string, unknown>
return NextResponse.json(updateNamespace(tenantId, patch))
}

View File

@ -1,9 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import type { ComposePayload } from '@lib/mail/types'
export async function POST(request: NextRequest) {
const payload = (await request.json()) as ComposePayload
void payload
return NextResponse.json({ success: true })
}

View File

@ -1,139 +0,0 @@
'use client'
import { FormEvent, useCallback, useMemo, useState } from 'react'
import { Loader2, Send } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { sendMessage } from '@lib/mail/apiClient'
import type { ComposePayload } from '@lib/mail/types'
interface ComposeFormProps {
tenantId: string
}
function parseList(value: string): string[] {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
export default function ComposeForm({ tenantId }: ComposeFormProps) {
const router = useRouter()
const params = useSearchParams()
const replySubject = params.get('subject') ?? ''
const replyTo = params.get('to') ?? ''
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<ComposePayload>({
to: replyTo ? parseList(replyTo) : [],
cc: [],
bcc: [],
subject: replySubject,
text: '',
html: '',
attachments: [],
})
const [status, setStatus] = useState<string | null>(null)
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (form.to.length === 0) {
setStatus('至少填写一个收件人')
return
}
try {
setSubmitting(true)
await sendMessage(tenantId, form)
setStatus('已成功发送!')
setTimeout(() => {
router.push(`/panel/mail?tenantId=${tenantId}`)
}, 600)
} catch (error) {
setStatus(error instanceof Error ? error.message : '发送失败')
} finally {
setSubmitting(false)
}
},
[form, router, tenantId],
)
const handleFieldChange = useCallback(
(field: keyof ComposePayload, value: string | string[]) => {
setForm((prev) => ({
...prev,
[field]: Array.isArray(value) ? value : value,
}))
},
[],
)
const recipientsPreview = useMemo(() => form.to.join(', '), [form.to])
return (
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-6 shadow-[var(--shadow-md)]">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold text-[var(--color-heading)]"></h1>
<p className="text-sm text-[var(--color-text-subtle)]"> TLS-only 使 S3 </p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
value={recipientsPreview}
placeholder="user@example.com, second@example.com"
onChange={(event) => handleFieldChange('to', parseList(event.target.value))}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
placeholder="可选"
onChange={(event) => handleFieldChange('cc', parseList(event.target.value))}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
placeholder="可选"
onChange={(event) => handleFieldChange('bcc', parseList(event.target.value))}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
value={form.subject}
onChange={(event) => handleFieldChange('subject', event.target.value)}
/>
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<textarea
className="min-h-[200px] rounded-xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
value={form.text}
onChange={(event) => handleFieldChange('text', event.target.value)}
placeholder="请在此输入邮件内容,可使用 Markdown。"
/>
</label>
<div className="flex flex-col gap-2 rounded-xl border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-6 text-center text-sm text-[var(--color-text-subtle)]">
<p className="font-medium text-[var(--color-heading)]"></p>
<p> S3</p>
</div>
{status ? <p className="text-sm text-[var(--color-text-subtle)]">{status}</p> : null}
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-6 py-2 text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-md)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-70"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div>
</form>
)
}

View File

@ -1,59 +0,0 @@
'use client'
import type { MailLabel, MailListMessage } from '@lib/mail/types'
import MessageItem from './MessageItem'
interface InboxProps {
messages: MailListMessage[]
selectedMessageId: string | null
onSelect: (id: string) => void
loading?: boolean
labels: MailLabel[]
}
export default function Inbox({ messages, selectedMessageId, onSelect, loading, labels }: InboxProps) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[color:var(--color-surface-border)] px-4 py-2 text-xs text-[var(--color-text-subtle)]">
<span> {messages.length} </span>
<div className="flex items-center gap-2">
{labels.slice(0, 4).map((label) => (
<span
key={label.id}
className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-muted)] px-2 py-1"
>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: label.color ?? 'var(--color-primary)' }} />
{label.name}
</span>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="space-y-3 px-4 py-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="animate-pulse rounded-xl bg-[var(--color-surface-muted)] px-3 py-4" />
))}
</div>
) : messages.length ? (
<ul className="divide-y divide-[color:var(--color-surface-border)]">
{messages.map((message) => (
<MessageItem
key={message.id}
message={message}
active={message.id === selectedMessageId}
onSelect={() => onSelect(message.id)}
/>
))}
</ul>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-sm text-[var(--color-text-subtle)]">
<p className="text-base font-medium text-[var(--color-heading)]"></p>
<p></p>
</div>
)}
</div>
</div>
)
}

View File

@ -1,135 +0,0 @@
'use client'
import { ChangeEvent, useCallback, useEffect, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTenantAuthContext } from '@lib/mail/auth'
import MailDashboard from './MailDashboard'
const MAIL_DEMO_ENABLED =
process.env.NEXT_PUBLIC_MAIL_DEMO === 'true' ||
process.env.NEXT_PUBLIC_MAIL_DEMO === '1' ||
process.env.NODE_ENV !== 'production'
const MAIL_DEMO_TENANT_ID = process.env.NEXT_PUBLIC_MAIL_DEMO_TENANT_ID ?? 'default'
const MAIL_DEMO_TENANT_NAME = process.env.NEXT_PUBLIC_MAIL_DEMO_TENANT_NAME ?? '演示租户'
export default function MailCenter() {
const router = useRouter()
const params = useSearchParams()
const { tenants, defaultTenantId } = useTenantAuthContext()
const queryTenantId = params.get('tenantId')
const fallbackTenant = useMemo(
() =>
MAIL_DEMO_ENABLED && (!tenants || tenants.length === 0)
? { id: MAIL_DEMO_TENANT_ID, name: MAIL_DEMO_TENANT_NAME }
: null,
[tenants],
)
const tenantOptions = useMemo(() => {
if (tenants && tenants.length > 0) {
return tenants
}
return fallbackTenant ? [fallbackTenant] : []
}, [fallbackTenant, tenants])
const preferredTenantId = useMemo(() => {
if (queryTenantId) {
return queryTenantId
}
if (defaultTenantId && tenantOptions.some((tenant) => tenant.id === defaultTenantId)) {
return defaultTenantId
}
return tenantOptions[0]?.id ?? null
}, [defaultTenantId, queryTenantId, tenantOptions])
useEffect(() => {
if (!queryTenantId && preferredTenantId) {
router.replace(`/panel/mail?tenantId=${preferredTenantId}`, { scroll: false })
}
}, [preferredTenantId, queryTenantId, router])
const tenantId = queryTenantId ?? preferredTenantId
const handleTenantChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value
if (!value) {
return
}
router.replace(`/panel/mail?tenantId=${value}`)
},
[router],
)
if (!tenantOptions.length) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-12 text-center text-[var(--color-text-subtle)] shadow-[var(--shadow-md)]">
<p className="text-lg font-semibold text-[var(--color-heading)]"></p>
<p className="mt-2 max-w-md text-sm">访</p>
</div>
)
}
if (!tenantId) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-4 rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-12 text-center text-[var(--color-text-subtle)] shadow-[var(--shadow-md)]">
<p className="text-lg font-semibold text-[var(--color-heading)]"></p>
<select
className="w-full max-w-xs rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
onChange={handleTenantChange}
defaultValue=""
>
<option value="" disabled>
</option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name ?? tenant.id}
</option>
))}
</select>
</div>
)
}
const activeTenant = tenantOptions.find((tenant) => tenant.id === tenantId)
const showDemoBanner = Boolean(fallbackTenant && tenantOptions.length === 1 && tenantOptions[0].id === fallbackTenant.id)
return (
<div className="flex flex-col gap-4">
{showDemoBanner ? (
<div className="rounded-2xl border border-dashed border-[color:var(--color-primary-border)] bg-[var(--color-primary-muted)] px-5 py-4 text-sm text-[var(--color-primary)] shadow-[var(--shadow-sm)]">
<p className="font-semibold text-[var(--color-primary)]"></p>
<p className="mt-1 text-[var(--color-primary)]/80">
docs/dashboard-mail-module-plan.md
</p>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] px-5 py-4 shadow-[var(--shadow-md)]">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold text-[var(--color-heading)]"></h1>
<p className="text-sm text-[var(--color-text-subtle)]">AI </p>
</div>
<label className="inline-flex items-center gap-2 text-sm text-[var(--color-text-subtle)]">
<span></span>
<select
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
value={tenantId}
onChange={handleTenantChange}
>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name ?? tenant.id}
</option>
))}
</select>
</label>
</div>
<MailDashboard tenantId={tenantId} tenantName={activeTenant?.name ?? fallbackTenant?.name ?? tenantId} />
</div>
)
}

View File

@ -1,82 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import useSWR from 'swr'
import { fetchInbox } from '@lib/mail/apiClient'
import type { MailListMessage, MailLabel, MailInboxResponse } from '@lib/mail/types'
import { useMailStore } from '../../store/mail.store'
import Inbox from './Inbox'
import MessageView from './MessageView'
import Toolbar from './Toolbar'
interface MailDashboardProps {
tenantId: string
tenantName: string
}
export default function MailDashboard({ tenantId, tenantName }: MailDashboardProps) {
const { label, search, pageSize, cursor, setTenant, setSelectedMessageId, selectedMessageId } = useMailStore()
useEffect(() => {
setTenant(tenantId)
}, [setTenant, tenantId])
const inboxKey = useMemo(
() =>
tenantId
? ['mail-inbox', tenantId, label ?? 'all', search, pageSize, cursor ?? '']
: null,
[cursor, label, pageSize, search, tenantId],
)
const inbox = useSWR<MailInboxResponse>(inboxKey, () => fetchInbox(tenantId, { cursor, label, pageSize, q: search }), {
keepPreviousData: true,
})
const messages: MailListMessage[] = inbox.data?.messages ?? []
const labels: MailLabel[] = inbox.data?.labels ?? []
useEffect(() => {
const current = inbox.data?.messages ?? []
if (!current.length) {
setSelectedMessageId(null)
return
}
const stillExists = current.some((message) => message.id === selectedMessageId)
if (!stillExists) {
setSelectedMessageId(current[0]?.id ?? null)
}
}, [inbox.data, selectedMessageId, setSelectedMessageId])
return (
<div className="grid h-[calc(100vh-280px)] gap-4 rounded-2xl lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex flex-col overflow-hidden rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
<Toolbar
tenantId={tenantId}
loading={inbox.isLoading}
refresh={() => inbox.mutate()}
labels={labels}
tenantName={tenantName}
/>
<Inbox
loading={inbox.isLoading}
messages={messages}
selectedMessageId={selectedMessageId}
onSelect={setSelectedMessageId}
labels={labels}
/>
</div>
<div className="min-h-0 overflow-hidden rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
<MessageView
tenantId={tenantId}
messageId={selectedMessageId}
onDeleted={() => inbox.mutate()}
onRefreshed={() => inbox.mutate()}
/>
</div>
</div>
)
}

View File

@ -1,148 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import useSWR from 'swr'
import { Loader2, Save } from 'lucide-react'
import { fetchNamespacePolicy, updateNamespacePolicy } from '@lib/mail/apiClient'
import type { NamespacePolicy } from '@lib/mail/types'
interface MailSettingsProps {
tenantId: string
}
export default function MailSettings({ tenantId }: MailSettingsProps) {
const namespace = useSWR<NamespacePolicy>(['mail-namespace', tenantId], () => fetchNamespacePolicy(tenantId))
const [draft, setDraft] = useState<Partial<NamespacePolicy> | null>(null)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const data = useMemo(() => {
if (!namespace.data) {
return null
}
return draft ? { ...namespace.data, ...draft } : namespace.data
}, [draft, namespace.data])
const handleChange = useCallback((field: keyof NamespacePolicy, value: string) => {
setDraft((prev) => ({
...(prev ?? {}),
[field]: field === 'temperature' ? parseFloat(value) : field === 'maxTokens' || field === 'rateLimitPerMinute' ? Number(value) : value,
}))
}, [])
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!data) {
return
}
try {
setSaving(true)
const updated = await updateNamespacePolicy(tenantId, draft ?? {})
namespace.mutate(updated, false)
setDraft(null)
setStatus('命名空间已更新')
} catch (error) {
setStatus(error instanceof Error ? error.message : '更新失败')
} finally {
setSaving(false)
}
},
[data, draft, namespace, tenantId],
)
if (namespace.isLoading || !data) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center gap-3 rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-12 text-sm text-[var(--color-text-subtle)]">
<Loader2 className="h-6 w-6 animate-spin text-[var(--color-primary)]" />
<p> AI </p>
</div>
)
}
return (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-6 shadow-[var(--shadow-md)]"
>
<div>
<h1 className="text-2xl font-semibold text-[var(--color-heading)]">AI </h1>
<p className="mt-2 text-sm text-[var(--color-text-subtle)]">
AI
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
value={data.model}
onChange={(event) => handleChange('model', event.target.value)}
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
type="number"
step="0.1"
min="0"
max="1"
value={data.temperature}
onChange={(event) => handleChange('temperature', event.target.value)}
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"> Token</span>
<input
type="number"
value={data.maxTokens}
onChange={(event) => handleChange('maxTokens', event.target.value)}
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
type="number"
value={data.rateLimitPerMinute}
onChange={(event) => handleChange('rateLimitPerMinute', event.target.value)}
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
/>
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<input
value={data.vectorIndex ?? ''}
onChange={(event) => handleChange('vectorIndex', event.target.value)}
className="rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
placeholder="s3://tenant-mail-embeddings"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-[var(--color-text-subtle)]"></span>
<textarea
value={data.policy ?? ''}
onChange={(event) => handleChange('policy', event.target.value)}
className="min-h-[160px] rounded-xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[color:var(--color-primary)] focus:outline-none"
placeholder='{"blockedKeywords": ["secret", "NDA"]}'
/>
</label>
<div className="flex items-center justify-between text-xs text-[var(--color-text-subtle)]">
<span>{new Date(data.updatedAt).toLocaleString()}</span>
{status ? <span>{status}</span> : null}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-6 py-2 text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-md)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</button>
</div>
</form>
)
}

View File

@ -1,79 +0,0 @@
'use client'
import { MailOpen, Star, StarOff } from 'lucide-react'
import type { MailListMessage } from '@lib/mail/types'
interface MessageItemProps {
message: MailListMessage
active?: boolean
onSelect: () => void
}
function formatAddress(address: { name?: string; email: string }) {
if (address.name && address.name.trim().length > 0) {
return `${address.name} <${address.email}>`
}
return address.email
}
function formatDate(input: string) {
const date = new Date(input)
return date.toLocaleString()
}
export default function MessageItem({ message, active, onSelect }: MessageItemProps) {
return (
<li>
<button
type="button"
onClick={onSelect}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition hover:bg-[var(--color-surface-hover)] ${
active ? 'bg-[var(--color-primary-muted)]' : ''
}`}
>
<span
className={`mt-1 flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold ${
message.unread
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]'
}`}
>
{message.unread ? '新' : <MailOpen className="h-3.5 w-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className={`truncate text-sm font-semibold ${message.unread ? 'text-[var(--color-heading)]' : ''}`}>
{formatAddress(message.from)}
</p>
{message.labels.map((label) => (
<span
key={label}
className="inline-flex items-center rounded-full bg-[var(--color-surface-muted)] px-2 py-0.5 text-[10px] uppercase text-[var(--color-text-subtle)]"
>
{label}
</span>
))}
</div>
<p className="mt-1 truncate text-sm text-[var(--color-heading)]">{message.subject}</p>
<p className="mt-0.5 line-clamp-2 text-xs text-[var(--color-text-subtle)]">{message.snippet}</p>
{message.aiSummary?.preview ? (
<p className="mt-1 line-clamp-2 text-xs text-[var(--color-accent)]">AI{message.aiSummary.preview}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-xs text-[var(--color-text-subtle)]">
<span>{formatDate(message.date)}</span>
<span className="text-[var(--color-text-subtle)]">
{message.hasAttachments ? '📎' : null}
</span>
<span className="text-[var(--color-warning-foreground)]">
{message.aiSummary?.tone}
</span>
</div>
<div className="pl-2">
{message.starred ? <Star className="h-4 w-4 text-yellow-400" /> : <StarOff className="h-4 w-4 text-[var(--color-text-subtle)]" />}
</div>
</button>
</li>
)
}

View File

@ -1,295 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import useSWR from 'swr'
import { ArrowLeft, FileDown, Loader2, Reply, Sparkles, Trash2 } from 'lucide-react'
import {
classifyMessage,
deleteMessage,
fetchMessage,
suggestReplies,
summarizeMessage,
} from '@lib/mail/apiClient'
import type { MailMessageDetail } from '@lib/mail/types'
interface MessageViewProps {
tenantId: string
messageId: string | null
onDeleted?: () => void
onRefreshed?: () => void
showBackButton?: boolean
onBack?: () => void
}
function formatAddress(address: { name?: string; email: string }) {
if (address.name && address.name.trim()) {
return `${address.name} <${address.email}>`
}
return address.email
}
function renderHtml(content?: string) {
if (!content) {
return null
}
return { __html: content }
}
export default function MessageView({ tenantId, messageId, onDeleted, onRefreshed, showBackButton, onBack }: MessageViewProps) {
const [aiBusy, setAiBusy] = useState(false)
const message = useSWR<MailMessageDetail>(
messageId ? ['mail-message', tenantId, messageId] : null,
() => fetchMessage(tenantId, messageId!),
)
const handleSummarize = useCallback(async () => {
if (!messageId) {
return
}
try {
setAiBusy(true)
const summary = await summarizeMessage(tenantId, { messageId })
message.mutate((prev) => (prev ? { ...prev, aiInsights: summary } : prev), false)
onRefreshed?.()
} finally {
setAiBusy(false)
}
}, [message, messageId, onRefreshed, tenantId])
const handleSuggestReplies = useCallback(async () => {
if (!messageId) {
return
}
try {
setAiBusy(true)
const { suggestions } = await suggestReplies(tenantId, { messageId, style: 'concise', language: 'zh' })
message.mutate((prev) =>
prev
? {
...prev,
aiInsights: {
...(prev.aiInsights ?? { summary: '', bullets: [], actions: [], tone: '' }),
suggestions,
},
}
: prev,
false,
)
onRefreshed?.()
} finally {
setAiBusy(false)
}
}, [message, messageId, onRefreshed, tenantId])
const handleDelete = useCallback(async () => {
if (!messageId) {
return
}
await deleteMessage(tenantId, messageId)
message.mutate(undefined, false)
onDeleted?.()
}, [message, messageId, onDeleted, tenantId])
const handleClassify = useCallback(async () => {
if (!messageId) {
return
}
try {
setAiBusy(true)
const classification = await classifyMessage(tenantId, messageId)
message.mutate((prev) =>
prev
? {
...prev,
labels: Array.from(new Set([...(prev.labels ?? []), ...classification.labels])),
}
: prev,
false,
)
onRefreshed?.()
} finally {
setAiBusy(false)
}
}, [message, messageId, onRefreshed, tenantId])
const insights = message.data?.aiInsights
const header = useMemo(() => {
if (!message.data) {
return null
}
return (
<div className="flex flex-col gap-2 border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
{showBackButton ? (
<button
type="button"
onClick={onBack}
className="inline-flex items-center rounded-full border border-[color:var(--color-surface-border)] px-3 py-1 text-xs text-[var(--color-text-subtle)] hover:text-[var(--color-primary)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
) : null}
<h2 className="text-lg font-semibold text-[var(--color-heading)]">{message.data.subject}</h2>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-subtle)]">
<button
type="button"
onClick={handleSummarize}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-primary-border)] px-3 py-1 text-[var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
disabled={aiBusy}
>
{aiBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={handleSuggestReplies}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1 hover:border-[color:var(--color-primary-border)]"
disabled={aiBusy}
>
<Reply className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleClassify}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1 hover:border-[color:var(--color-primary-border)]"
disabled={aiBusy}
>
<Sparkles className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-danger-muted)] px-3 py-1 text-[var(--color-danger-foreground)] hover:bg-[var(--color-danger-muted)]"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex flex-col gap-1 text-sm text-[var(--color-text-subtle)]">
<span>
<strong className="text-[var(--color-heading)]">{formatAddress(message.data.from)}</strong>
</span>
<span>{new Date(message.data.date).toLocaleString()}</span>
<span>
{message.data.to.map((recipient) => formatAddress(recipient)).join(', ')}
</span>
{message.data.cc?.length ? (
<span>{message.data.cc.map((recipient) => formatAddress(recipient)).join(', ')}</span>
) : null}
</div>
</div>
)
}, [aiBusy, handleClassify, handleDelete, handleSummarize, handleSuggestReplies, message.data, onBack, showBackButton])
if (!messageId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center text-sm text-[var(--color-text-subtle)]">
<Sparkles className="h-8 w-8 text-[var(--color-primary)]" />
<p className="text-base font-medium text-[var(--color-heading)]"></p>
<p>AI </p>
</div>
)
}
if (message.error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-12 text-center text-sm text-[var(--color-text-subtle)]">
<p className="text-base font-semibold text-[var(--color-heading)]"></p>
<p>{message.error instanceof Error ? message.error.message : 'Unknown error'}</p>
</div>
)
}
if (!message.data) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center text-sm text-[var(--color-text-subtle)]">
<Loader2 className="h-6 w-6 animate-spin text-[var(--color-primary)]" />
<p></p>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-hidden">
{header}
<div className="flex-1 overflow-y-auto px-6 py-4">
{insights ? (
<div className="space-y-3 rounded-xl border border-[color:var(--color-primary-border)] bg-[var(--color-primary-muted)] px-4 py-3 text-sm text-[var(--color-text)]">
<div className="flex items-center justify-between text-sm">
<p className="font-semibold text-[var(--color-primary)]">AI </p>
{insights.tone ? <span className="text-xs text-[var(--color-text-subtle)]">{insights.tone}</span> : null}
</div>
<p className="text-sm leading-relaxed">{insights.summary}</p>
{insights.bullets.length ? (
<ul className="list-disc space-y-1 pl-5 text-sm">
{insights.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
{insights.actions.length ? (
<div>
<p className="text-xs font-semibold text-[var(--color-heading)]"></p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm">
{insights.actions.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
{insights.suggestions?.length ? (
<div className="rounded-lg bg-[var(--color-surface-elevated)] px-3 py-2 text-xs text-[var(--color-text-subtle)]">
<p className="font-semibold text-[var(--color-heading)]"></p>
<ul className="mt-1 space-y-1">
{insights.suggestions.map((suggestion, index) => (
<li key={index} className="rounded-md bg-[var(--color-surface-muted)] px-2 py-1 text-[var(--color-text)]">
{suggestion}
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
<article className="prose prose-sm mt-6 max-w-none text-[var(--color-text)] prose-headings:text-[var(--color-heading)]">
{message.data.html ? (
<div dangerouslySetInnerHTML={renderHtml(message.data.html) ?? undefined} />
) : (
<pre className="whitespace-pre-wrap rounded-xl bg-[var(--color-surface-muted)] px-4 py-3 text-sm text-[var(--color-text)]">
{message.data.text ?? message.data.snippet}
</pre>
)}
</article>
{message.data.attachments?.length ? (
<div className="mt-6 space-y-3">
<h3 className="text-sm font-semibold text-[var(--color-heading)]"></h3>
<ul className="space-y-2 text-sm">
{message.data.attachments.map((attachment) => (
<li
key={attachment.id}
className="flex items-center justify-between rounded-lg border border-[color:var(--color-surface-border)] px-3 py-2 text-[var(--color-text-subtle)]"
>
<div>
<p className="text-sm font-medium text-[var(--color-heading)]">{attachment.fileName}</p>
<p className="text-xs">{attachment.contentType}</p>
</div>
{attachment.downloadUrl ? (
<a
href={attachment.downloadUrl}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-primary-border)] px-3 py-1 text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
>
<FileDown className="h-3.5 w-3.5" />
</a>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
)
}

View File

@ -1,110 +0,0 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { Filter, Loader2, MailPlus, RefreshCw, Search } from 'lucide-react'
import { useMailStore } from '../../store/mail.store'
import type { MailLabel } from '@lib/mail/types'
interface ToolbarProps {
tenantId: string
tenantName: string
loading?: boolean
refresh: () => void
labels: MailLabel[]
}
const PRESET_FILTERS: Array<{ id: string | null; label: string }> = [
{ id: null, label: '全部' },
{ id: 'unread', label: '未读' },
{ id: 'starred', label: '星标' },
{ id: 'important', label: '重要' },
]
export default function Toolbar({ tenantId, tenantName, loading, refresh, labels }: ToolbarProps) {
const { label, setLabel, search, setSearch } = useMailStore()
const [expanded, setExpanded] = useState(false)
return (
<div className="flex flex-col gap-3 border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[var(--color-text-subtle)]">
<Filter className="h-4 w-4" />
<span>{tenantName}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={refresh}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1.5 text-sm text-[var(--color-text-subtle)] transition hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)]"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</button>
<Link
href={`/panel/mail/compose?tenantId=${tenantId}`}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition hover:bg-[var(--color-primary-hover)]"
>
<MailPlus className="h-4 w-4" />
</Link>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{PRESET_FILTERS.map((filter) => (
<button
key={filter.id ?? 'all'}
type="button"
onClick={() => setLabel(filter.id)}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs transition ${
label === filter.id
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] hover:text-[var(--color-primary)]'
}`}
>
{filter.label}
</button>
))}
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-muted)] px-3 py-1 text-xs text-[var(--color-text-subtle)] transition hover:text-[var(--color-primary)]"
>
<Filter className="h-3 w-3" />
</button>
</div>
{expanded ? (
<div className="flex flex-wrap gap-2 text-xs text-[var(--color-text-subtle)]">
{labels.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setLabel(item.id)}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 transition ${
label === item.id
? 'border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)]'
: 'border-[color:var(--color-surface-border)] hover:border-[color:var(--color-primary-border)]'
}`}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.color ?? 'var(--color-primary)' }}
/>
{item.name}
{typeof item.unread === 'number' ? <span>({item.unread})</span> : null}
</button>
))}
</div>
) : null}
<div className="flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-sm text-[var(--color-text-subtle)]">
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索发件人、主题或内容"
className="flex-1 bg-transparent text-sm text-[var(--color-text)] focus:outline-none"
/>
</div>
</div>
)
}

View File

@ -1,15 +0,0 @@
'use client'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
export default function DemoContent() {
const { language } = useLanguage()
const { account } = translations[language].nav
return (
<div className="min-h-screen flex items-center justify-center">
<h1 className="text-2xl font-bold">{account.demo}</h1>
</div>
)
}

View File

@ -1,7 +0,0 @@
import HomepageLanding from '@modules/homepage/page'
export const dynamic = 'force-static'
export default function MarkdownDemoPage() {
return <HomepageLanding />
}

View File

@ -1,14 +0,0 @@
export const dynamic = 'error'
import { notFound } from 'next/navigation'
import { isFeatureEnabled } from '@lib/featureToggles'
import DemoContent from './DemoContent'
export default function DemoPage() {
if (!isFeatureEnabled('appModules', '/demo')) {
notFound()
}
return <DemoContent />
}

View File

@ -1,62 +0,0 @@
'use client'
import ThemePreferenceCard from '../../panel/account/ThemePreferenceCard'
import Card from '../../panel/components/Card'
import { useTheme } from '@components/theme'
import type { ThemeTokens } from '@components/theme'
const COLOR_TOKENS: Array<{ key: keyof ThemeTokens['colors']; label: string }> = [
{ key: 'background', label: '背景 Background' },
{ key: 'surface', label: '表面 Surface' },
{ key: 'surface-muted', label: '次级表面 Muted surface' },
{ key: 'text', label: '正文 Text' },
{ key: 'text-subtle', label: '辅助文本 Muted text' },
{ key: 'primary', label: '主色 Primary' },
{ key: 'primary-muted', label: '主色衬底 Primary muted' },
{ key: 'primary-foreground', label: '主色文字 Primary foreground' },
]
export default function ThemeShowcasePage() {
const { resolvedTheme, tokens } = useTheme()
const { colors } = tokens
return (
<div className="mx-auto max-w-5xl space-y-8 px-4 py-12 text-[var(--color-text)] transition-colors sm:px-6 lg:px-8">
<header className="space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-[var(--color-primary)]">Theme system</p>
<h1 className="text-3xl font-bold text-[var(--color-heading)]"></h1>
<p className="max-w-3xl text-sm text-[var(--color-text-subtle)]">
<span className="font-semibold text-[var(--color-text)]">{resolvedTheme}</span>使 token
</p>
</header>
<ThemePreferenceCard />
<Card className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--color-text)]"> token</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
<code className="rounded bg-[var(--color-surface-muted)] px-2 py-1 text-xs">document.documentElement</code> Tailwind
CSS
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{COLOR_TOKENS.map(({ key, label }) => {
const value = colors[key]
return (
<div
key={key}
className="flex flex-col gap-2 rounded-[var(--radius-lg)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] p-3 text-xs text-[var(--color-text-subtle)]"
>
<span className="font-semibold text-[var(--color-text)]">{label}</span>
<span
className="rounded-[var(--radius-lg)] border border-[color:var(--color-surface-border)] px-3 py-6"
style={{ backgroundColor: value }}
/>
<span className="font-mono text-[var(--color-text-subtle)] opacity-80">{value}</span>
</div>
)
})}
</div>
</Card>
</div>
)
}

View File

@ -1,17 +0,0 @@
export const dynamic = 'error'
import { redirect } from 'next/navigation'
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function MailPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/mail')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -2,7 +2,6 @@ import type { MetadataRoute } from 'next'
import { getBlogPosts } from '@/lib/blogContent'
import { getDocCollections } from '@/lib/docContent'
import { allWorkshops } from 'contentlayer/generated'
import { PRODUCT_LIST } from '@/modules/products/registry'
const baseUrl = 'https://console.svc.plus'
@ -44,11 +43,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${baseUrl}/workshop`,
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${baseUrl}/cloud_iac`,
changeFrequency: 'monthly',
@ -88,12 +83,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
})),
)
const workshopEntries: MetadataRoute.Sitemap = allWorkshops.map((workshop) => ({
url: `${baseUrl}/workshop/${workshop.slug}`,
lastModified: workshop.updatedAt ? new Date(workshop.updatedAt) : undefined,
changeFrequency: 'monthly',
priority: 0.6,
}))
return [...staticEntries, ...productEntries, ...blogEntries, ...docsEntries, ...workshopEntries]
return [...staticEntries, ...productEntries, ...blogEntries, ...docsEntries]
}

View File

@ -1,54 +0,0 @@
'use client'
import { create } from 'zustand'
interface MailState {
tenantId: string | null
selectedMessageId: string | null
label: string | null
search: string
pageSize: number
cursor: string | null
setTenant: (tenantId: string) => void
setSelectedMessageId: (id: string | null) => void
setLabel: (label: string | null) => void
setSearch: (term: string) => void
setCursor: (cursor: string | null) => void
setPageSize: (size: number) => void
reset: () => void
}
const DEFAULT_STATE: Omit<MailState, 'setTenant' | 'setSelectedMessageId' | 'setLabel' | 'setSearch' | 'setCursor' | 'setPageSize' | 'reset'> = {
tenantId: null,
selectedMessageId: null,
label: null,
search: '',
pageSize: 25,
cursor: null,
}
export const useMailStore = create<MailState>((set) => ({
...DEFAULT_STATE,
setTenant: (tenantId: string) =>
set((state) => ({
...DEFAULT_STATE,
tenantId,
search: state.search,
})),
setSelectedMessageId: (id) => set({ selectedMessageId: id }),
setLabel: (label) =>
set((state) => ({
label,
cursor: null,
selectedMessageId: state.selectedMessageId,
})),
setSearch: (term) =>
set((state) => ({
search: term,
cursor: null,
selectedMessageId: state.selectedMessageId,
})),
setCursor: (cursor) => set({ cursor }),
setPageSize: (size) => set({ pageSize: size }),
reset: () => set(DEFAULT_STATE),
}))

View File

@ -1,53 +0,0 @@
export const dynamic = 'error'
export const revalidate = false
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import WorkshopArticle from '@/components/workshop/WorkshopArticle'
import { allWorkshops } from 'contentlayer/generated'
export const generateStaticParams = async () => allWorkshops.map((workshop) => ({ slug: workshop.slug }))
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const workshop = allWorkshops.find((entry) => entry.slug === params.slug)
if (!workshop) {
return { title: 'Workshop | Cloud-Neutral' }
}
return {
title: `${workshop.title} | Workshop`,
description: workshop.summary,
}
}
export default function WorkshopDetailPage({ params }: { params: { slug: string } }) {
const workshop = allWorkshops.find((entry) => entry.slug === params.slug)
if (!workshop) {
notFound()
}
return (
<main className="bg-brand-surface px-6 py-12 sm:px-8">
<div className="mx-auto flex max-w-5xl flex-col gap-8">
<header className="space-y-3 rounded-3xl border border-brand-border bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-brand">Workshop</p>
<h1 className="text-3xl font-bold text-brand-heading md:text-[36px]">{workshop.title}</h1>
<p className="text-sm text-brand-heading/80">{workshop.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-brand-heading/70">
<span className="rounded-full bg-brand-surface px-3 py-1 font-semibold text-brand-heading">{workshop.level}</span>
{workshop.duration && <span>{workshop.duration}</span>}
{workshop.updatedAt && <span suppressHydrationWarning>Updated {workshop.updatedAt}</span>}
</div>
</header>
<div className="rounded-3xl border border-brand-border bg-white p-6 shadow-sm">
<WorkshopArticle code={workshop.body.code} />
</div>
</div>
</main>
)
}

View File

@ -1,47 +0,0 @@
import type { Metadata } from 'next'
import WorkshopCard from '@/components/workshop/WorkshopCard'
import { allWorkshops } from 'contentlayer/generated'
export const dynamic = 'error'
export const revalidate = false
export const metadata: Metadata = {
title: 'Workshops | Cloud-Neutral',
description: 'Hands-on, short-lived experiments built with MDX and Contentlayer.',
}
export default function WorkshopIndexPage() {
const workshops = [...allWorkshops].sort((a, b) => {
const aDate = a.updatedAt ? Date.parse(a.updatedAt) : 0
const bDate = b.updatedAt ? Date.parse(b.updatedAt) : 0
if (aDate && bDate && aDate !== bDate) return bDate - aDate
return a.title.localeCompare(b.title)
})
return (
<main className="bg-brand-surface px-6 py-12 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-10">
<header className="space-y-4 text-brand-heading">
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-brand">Workshop</p>
<h1 className="text-[32px] font-bold text-brand md:text-[36px]">Interactive Workflows</h1>
<p className="max-w-3xl text-sm text-brand-heading/80 md:text-base">
Short-lived, high-interaction guides compiled with Contentlayer. Experiment with toggles and demos without changing the core UI.
</p>
</header>
{workshops.length === 0 ? (
<div className="rounded-2xl border border-dashed border-brand-border bg-white p-10 text-center text-sm text-brand-heading/70">
Workshops will appear here once content is published.
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{workshops.map((workshop) => (
<WorkshopCard key={workshop.slug} workshop={workshop} />
))}
</div>
)}
</div>
</main>
)
}

View File

@ -88,7 +88,7 @@ const productCards: Record<'zh' | 'en', FeatureCard[]> = {
{
title: 'XCloudFlow',
description: 'Pulumi 引擎驱动的多云 IaC统一 DevOps 与合规审计。',
href: '/demo?product=xcloudflow',
href: '/xcloudflow',
badge: 'IaC + GitOps',
},
{
@ -108,7 +108,7 @@ const productCards: Record<'zh' | 'en', FeatureCard[]> = {
{
title: 'XCloudFlow',
description: 'Multi-cloud IaC powered by Pulumi with built-in compliance guardrails.',
href: '/demo?product=xcloudflow',
href: '/xcloudflow',
badge: 'IaC + GitOps',
},
{

View File

@ -165,12 +165,6 @@ export default function Navbar() {
href: "/login",
togglePath: "/login",
},
{
key: "demo",
label: nav.account.demo,
href: "/demo",
togglePath: "/demo",
},
];
const accountLabel = nav.account.title;

View File

@ -1,18 +0,0 @@
'use client'
import { useMDXComponent } from 'next-contentlayer/hooks'
import WorkshopDemo from './WorkshopDemo'
interface WorkshopArticleProps {
code: string
}
export default function WorkshopArticle({ code }: WorkshopArticleProps) {
const MDXContent = useMDXComponent(code)
return (
<article className="prose prose-slate max-w-none prose-a:text-brand">
<MDXContent components={{ WorkshopDemo }} />
</article>
)
}

View File

@ -1,40 +0,0 @@
import Link from 'next/link'
import type { Workshop } from 'contentlayer/generated'
interface WorkshopCardProps {
workshop: Workshop
}
export default function WorkshopCard({ workshop }: WorkshopCardProps) {
return (
<article className="flex h-full flex-col justify-between rounded-2xl border border-brand-border bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-md">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-brand">Workshop</p>
<h2 className="text-lg font-semibold text-brand-heading">{workshop.title}</h2>
<p className="text-sm text-brand-heading/80">{workshop.summary}</p>
{workshop.tags?.length ? (
<div className="flex flex-wrap gap-2 pt-2">
{workshop.tags.map((tag) => (
<span key={tag} className="rounded-full border border-brand-border bg-brand-surface px-3 py-1 text-xs font-medium">
{tag}
</span>
))}
</div>
) : null}
</div>
<div className="mt-6 flex items-center justify-between text-sm text-brand-heading/80">
<div className="flex items-center gap-2">
<span className="rounded-full bg-brand-surface px-3 py-1 text-xs font-semibold text-brand-heading">{workshop.level}</span>
{workshop.duration && <span>{workshop.duration}</span>}
</div>
<Link
href={workshop.url}
className="rounded-full border border-brand-border bg-brand-surface px-3 py-1 text-sm font-semibold text-brand transition hover:border-brand hover:bg-white"
>
View
</Link>
</div>
</article>
)
}

View File

@ -1,51 +0,0 @@
'use client'
import { useState } from 'react'
export default function WorkshopDemo() {
const [environment, setEnvironment] = useState<'staging' | 'production'>('staging')
const [enabled, setEnabled] = useState(false)
return (
<div className="rounded-2xl border border-brand-border bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-brand">Live Toggle</p>
<p className="text-sm text-brand-heading/80">Switch environments to preview workshop actions.</p>
</div>
<label className="flex items-center gap-2 text-sm font-semibold text-brand-heading">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className="h-4 w-4 rounded border-brand-border text-brand focus:ring-brand"
/>
{enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
<div className="mt-4 flex items-center gap-3">
{(['staging', 'production'] as const).map((item) => {
const isActive = environment === item
return (
<button
key={item}
type="button"
onClick={() => setEnvironment(item)}
className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
isActive ? 'border-brand bg-brand text-white' : 'border-brand-border bg-brand-surface text-brand-heading'
}`}
>
{item === 'staging' ? 'Staging' : 'Production'}
</button>
)
})}
</div>
<div className="mt-4 rounded-xl bg-brand-surface p-3 text-sm text-brand-heading/80">
<p className="font-semibold text-brand-heading">
{enabled ? 'Automation ready' : 'Preview mode'} · {environment}
</p>
<p className="text-xs text-brand-heading/70">Stateful interactions stay inside workshop scope.</p>
</div>
</div>
)
}

View File

@ -1,25 +0,0 @@
---
title: Fast Feedback Loops
summary: Prototype change workflows with interactive toggles before wiring production automation.
level: Intermediate
duration: 25 min
tags:
- gitops
- automation
- rollout
updatedAt: 2024-12-01
---
Live demos in this workshop stay close to how engineers actually deploy. Toggle environments, flip feature states, and observe how the rollout recipe responds in real time.
## What you will build
- A staged toggle flow that mirrors your GitOps pipelines.
- A small guardrail to ensure approvals stay attached to risky rollouts.
- A preview card that highlights the blast radius of each action.
## Try it now
<WorkshopDemo />
Because the MDX runs through Contentlayer, interactive components compile at build time while preserving state on the client.

View File

@ -482,7 +482,6 @@ type UserCenterTranslation = {
}
items: {
dashboard: string
mail: string
agents: string
apis: string
accounts: string
@ -977,7 +976,6 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
items: {
dashboard: 'Dashboard',
mail: 'Mail',
agents: 'Agents',
apis: 'APIs',
accounts: 'Accounts',
@ -1749,7 +1747,6 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
items: {
dashboard: '仪表盘',
mail: '邮箱服务',
agents: '运行节点',
apis: '接口集成',
accounts: '账户中心',

View File

@ -1,116 +0,0 @@
'use client'
import type {
ClassifyResponse,
ComposePayload,
MailInboxResponse,
MailMessageDetail,
NamespacePolicy,
ReplySuggestionRequest,
ReplySuggestionResponse,
SummarizeRequest,
SummarizeResponse,
} from './types'
import { buildTenantHeaders } from './auth'
const MAIL_API_BASE = '/api/mail'
async function request<T>(path: string, init: RequestInit & { tenantId: string }): Promise<T> {
const { tenantId, headers, ...rest } = init
const response = await fetch(`${MAIL_API_BASE}${path}`, {
...rest,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...buildTenantHeaders(tenantId),
...(headers as Record<string, string> | undefined),
},
cache: 'no-store',
})
if (!response.ok) {
const message = await response.text().catch(() => 'Request failed')
throw new Error(message || `Failed to fetch ${path}`)
}
if (response.status === 204) {
return null as T
}
return (await response.json()) as T
}
export function fetchInbox(tenantId: string, params: { cursor?: string | null; pageSize?: number; label?: string | null; q?: string | null }) {
const url = new URL(`${MAIL_API_BASE}/inbox`, typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
if (params.cursor) {
url.searchParams.set('cursor', params.cursor)
}
if (params.pageSize) {
url.searchParams.set('pageSize', String(params.pageSize))
}
if (params.label) {
url.searchParams.set('label', params.label)
}
if (params.q) {
url.searchParams.set('q', params.q)
}
return request<MailInboxResponse>(`${url.pathname}${url.search}`, { method: 'GET', tenantId })
}
export function fetchMessage(tenantId: string, messageId: string) {
return request<MailMessageDetail>(`/message/${messageId}`, { method: 'GET', tenantId })
}
export function sendMessage(tenantId: string, payload: ComposePayload) {
return request<{ success: boolean; message: MailMessageDetail }>(`/send`, {
method: 'POST',
tenantId,
body: JSON.stringify(payload),
})
}
export function deleteMessage(tenantId: string, messageId: string) {
return request<{ success: boolean }>(`/message/${messageId}`, {
method: 'DELETE',
tenantId,
})
}
export function summarizeMessage(tenantId: string, payload: SummarizeRequest) {
return request<SummarizeResponse>(`/ai/summarize`, {
method: 'POST',
tenantId,
body: JSON.stringify(payload),
})
}
export function suggestReplies(tenantId: string, payload: ReplySuggestionRequest) {
return request<ReplySuggestionResponse>(`/ai/reply-suggest`, {
method: 'POST',
tenantId,
body: JSON.stringify(payload),
})
}
export function classifyMessage(tenantId: string, messageId: string) {
return request<ClassifyResponse>(`/ai/classify`, {
method: 'POST',
tenantId,
body: JSON.stringify({ messageId }),
})
}
export function fetchNamespacePolicy(tenantId: string) {
return request<NamespacePolicy>(`/namespace`, {
method: 'GET',
tenantId,
})
}
export function updateNamespacePolicy(tenantId: string, policy: Partial<NamespacePolicy>) {
return request<NamespacePolicy>(`/namespace`, {
method: 'PUT',
tenantId,
body: JSON.stringify(policy),
})
}

View File

@ -1,31 +0,0 @@
'use client'
import { useMemo } from 'react'
import { useUserStore } from '@lib/userStore'
export function useTenantAuthContext() {
const user = useUserStore((state) => state.user)
return useMemo(() => {
const memberships = user?.tenants ?? []
const defaultTenant =
memberships.find((tenant) => tenant.id === user?.tenantId) ?? memberships[0] ?? (user?.tenantId ? { id: user.tenantId } : null)
return {
user,
defaultTenantId: defaultTenant?.id,
tenants: memberships,
}
}, [user])
}
export function buildTenantHeaders(tenantId: string, token?: string) {
const headers: Record<string, string> = {
'x-tenant-id': tenantId,
}
if (token) {
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
return headers
}

View File

@ -1,110 +0,0 @@
export type MailAddress = {
name?: string
email: string
}
export type MailAttachment = {
id: string
fileName: string
contentType: string
size: number
downloadUrl?: string
}
export type MailListMessage = {
id: string
subject: string
snippet: string
from: MailAddress
to: MailAddress[]
date: string
unread: boolean
starred?: boolean
labels: string[]
hasAttachments?: boolean
aiSummary?: {
preview: string
tone?: string
}
}
export type MailMessageDetail = {
id: string
subject: string
snippet: string
from: MailAddress
to: MailAddress[]
cc?: MailAddress[]
bcc?: MailAddress[]
date: string
unread: boolean
starred?: boolean
labels: string[]
text?: string
html?: string
attachments?: MailAttachment[]
aiInsights?: MailInsights
}
export type MailInsights = {
summary: string
bullets: string[]
actions: string[]
tone: string
suggestions?: string[]
}
export type MailInboxResponse = {
messages: MailListMessage[]
labels: MailLabel[]
unreadCount: number
nextCursor?: string | null
}
export type MailLabel = {
id: string
name: string
color?: string
unread?: number
}
export type ComposePayload = {
to: string[]
cc?: string[]
bcc?: string[]
subject: string
text?: string
html?: string
attachments?: string[]
}
export type NamespacePolicy = {
model: string
temperature: number
maxTokens: number
rateLimitPerMinute: number
vectorIndex?: string
policy?: string
updatedAt: string
}
export type ReplySuggestionRequest = {
messageId: string
style?: 'concise' | 'formal' | 'technical'
language?: 'zh' | 'en'
}
export type ReplySuggestionResponse = {
suggestions: string[]
}
export type SummarizeRequest = {
messageId?: string
raw?: string
}
export type SummarizeResponse = MailInsights
export type ClassifyResponse = {
labels: string[]
}

View File

@ -105,7 +105,7 @@ const HERO_SOLUTIONS: HeroSolution[] = [
bodyHtml:
'<p>XCloudFlow 将 Terraform、Pulumi 等主流 IaC 模型统一到一个工作台,为多云环境提供自助式交付与集中治理。</p>',
primaryCtaLabel: '立刻体验',
primaryCtaHref: '/demo?product=xcloudflow',
primaryCtaHref: '/xcloudflow',
secondaryCtaLabel: '下载链接',
secondaryCtaHref: '/download?product=xcloudflow',
tertiaryCtaLabel: '文档链接',
@ -120,7 +120,7 @@ const HERO_SOLUTIONS: HeroSolution[] = [
bodyHtml:
'<p>XScopeHub 通过语义化检索与时序分析,实现跨环境的可观察性汇聚与智能洞察。</p>',
primaryCtaLabel: '立刻体验',
primaryCtaHref: '/demo?product=xscopehub',
primaryCtaHref: '/xscopehub',
secondaryCtaLabel: '下载链接',
secondaryCtaHref: '/download?product=xscopehub',
tertiaryCtaLabel: '文档链接',
@ -135,7 +135,7 @@ const HERO_SOLUTIONS: HeroSolution[] = [
bodyHtml:
'<p>XStream 通过软件定义的网络加速技术,为实时互动、音视频与数据分发提供稳定的全球链路。</p>',
primaryCtaLabel: '立刻体验',
primaryCtaHref: '/demo?product=xstream',
primaryCtaHref: '/xstream',
secondaryCtaLabel: '下载链接',
secondaryCtaHref: '/download?product=xstream',
tertiaryCtaLabel: '文档链接',

View File

@ -1,4 +1,4 @@
import { Code, CreditCard, Home, Mail, Palette, Server, Settings, Shield, User } from 'lucide-react'
import { Code, CreditCard, Home, Palette, Server, Settings, Shield, User } from 'lucide-react'
import type { DashboardExtension } from '../../types'
@ -24,17 +24,6 @@ export const userCenterExtension: DashboardExtension = {
redirect: { unauthenticated: '/login' },
sidebar: { section: 'workspace', order: 0 },
},
{
id: 'mail',
path: '/panel/mail',
label: 'Mail',
description: '租户邮件与 AI 助理',
icon: Mail,
loader: () => import('./routes/mail'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: 'workspace', order: 1 },
},
{
id: 'agents',
path: '/panel/agent',

View File

@ -1,5 +0,0 @@
import MailCenter from '../../../../../app/components/mail/MailCenter'
export default function MailRoute() {
return <MailCenter />
}