chore: commit all local changes after local verification
This commit is contained in:
parent
b2f1c8a9b9
commit
f6a8f6ea41
@ -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: [],
|
||||
})
|
||||
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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: '信息',
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import HomepageLanding from '@modules/homepage/page'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export default function MarkdownDemoPage() {
|
||||
return <HomepageLanding />
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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.
|
||||
@ -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: '账户中心',
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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[]
|
||||
}
|
||||
@ -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: '文档链接',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import MailCenter from '../../../../../app/components/mail/MailCenter'
|
||||
|
||||
export default function MailRoute() {
|
||||
return <MailCenter />
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user