Selaa lähdekoodia

feat(ui): apply redesign chrome across remaining admin pages

AI-Co-Authored-By: Grok
chendeben 1 kuukausi sitten
vanhempi
sitoutus
8839304ce2

+ 17 - 0
src/frontend/styles.css

@@ -238,6 +238,16 @@ body {
   margin-bottom: 16px;
   margin-bottom: 16px;
 }
 }
 
 
+.token-secret-alert {
+  border: 1px solid #fecaca;
+  background: #fef2f2;
+}
+
+.token-secret-alert .ant-alert-message {
+  color: #991b1b;
+  font-weight: 600;
+}
+
 .api-doc-list {
 .api-doc-list {
   color: #475569;
   color: #475569;
   margin: 0;
   margin: 0;
@@ -691,6 +701,13 @@ body {
 
 
 .section-card {
 .section-card {
   border-radius: var(--mh-radius-card);
   border-radius: var(--mh-radius-card);
+  box-shadow: var(--mh-shadow-card);
+}
+
+.section-card .section-card {
+  box-shadow: none;
+  background: #f8fafc;
+  border-color: var(--mh-border);
 }
 }
 
 
 .status-pill {
 .status-pill {

+ 55 - 38
src/pages/Admin/index.tsx

@@ -9,10 +9,8 @@ import {
   Alert,
   Alert,
   App as AntApp,
   App as AntApp,
   Button,
   Button,
-  Card,
   Checkbox,
   Checkbox,
   Descriptions,
   Descriptions,
-  Empty,
   Form,
   Form,
   Input,
   Input,
   InputNumber,
   InputNumber,
@@ -26,8 +24,12 @@ import {
   Typography
   Typography
 } from 'antd';
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
 
 
+import { EmptyState } from '../../components/common/EmptyState';
+import { PageHeader } from '../../components/common/PageHeader';
+import { SectionCard } from '../../components/common/SectionCard';
+import { StatusPill } from '../../components/common/StatusPill';
 import {
 import {
   adminUserStatusMeta,
   adminUserStatusMeta,
   buildMergeConfirmationText,
   buildMergeConfirmationText,
@@ -40,10 +42,7 @@ import { api } from '../../frontend/services/api';
 import type {
 import type {
   AdminResourceInventory,
   AdminResourceInventory,
   AdminUser,
   AdminUser,
-  ApiToken,
   AuditLogEntry,
   AuditLogEntry,
-  DnsCredential,
-  Domain,
   SystemEmailSettings,
   SystemEmailSettings,
   User,
   User,
   UserMergeOptions,
   UserMergeOptions,
@@ -85,9 +84,12 @@ export default function AdminPage({ me }: AdminPageProps) {
 
 
   if (me?.role !== 'admin') {
   if (me?.role !== 'admin') {
     return (
     return (
-      <Card>
-        <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
-      </Card>
+      <Space direction="vertical" size={20} className="full-width">
+        <PageHeader title={t('admin.title')} />
+        <SectionCard>
+          <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
+        </SectionCard>
+      </Space>
     );
     );
   }
   }
 
 
@@ -229,16 +231,18 @@ export default function AdminPage({ me }: AdminPageProps) {
   ];
   ];
 
 
   return (
   return (
-    <Space direction="vertical" size={16} className="full-width">
-      <div className="page-toolbar">
-        <Typography.Title level={3}>{t('admin.title')}</Typography.Title>
-        <Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadAdminData()}>
-          {t('common.refresh')}
-        </Button>
-      </div>
-      <Card>
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader
+        title={t('admin.title')}
+        extra={
+          <Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadAdminData()}>
+            {t('common.refresh')}
+          </Button>
+        }
+      />
+      <SectionCard>
         <Tabs items={tabItems} />
         <Tabs items={tabItems} />
-      </Card>
+      </SectionCard>
     </Space>
     </Space>
   );
   );
 }
 }
@@ -419,7 +423,11 @@ function AdminResources({
     {
     {
       title: 'SMTP',
       title: 'SMTP',
       width: 120,
       width: 120,
-      render: (_, group) => group.smtpCredential ? <Tag color="green">已配置</Tag> : <Tag>无</Tag>
+      render: (_, group) => (
+        group.smtpCredential
+          ? <StatusPill tone="success">已配置</StatusPill>
+          : <StatusPill tone="neutral">无</StatusPill>
+      )
     }
     }
   ];
   ];
 
 
@@ -466,7 +474,7 @@ function AdminResources({
         />
         />
       ) : null}
       ) : null}
       <div className="form-grid three">
       <div className="form-grid three">
-        <Card title="迁移域名">
+        <SectionCard title="迁移域名">
           <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading}>
           <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading}>
             <Form.Item name="domainId" label="域名" rules={[{ required: true }]}>
             <Form.Item name="domainId" label="域名" rules={[{ required: true }]}>
               <Select
               <Select
@@ -492,8 +500,8 @@ function AdminResources({
             </Form.Item>
             </Form.Item>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
           </Form>
           </Form>
-        </Card>
-        <Card title="迁移 DNS 凭据">
+        </SectionCard>
+        <SectionCard title="迁移 DNS 凭据">
           <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading}>
           <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading}>
             <Form.Item name="credentialId" label="DNS 凭据" rules={[{ required: true }]}>
             <Form.Item name="credentialId" label="DNS 凭据" rules={[{ required: true }]}>
               <Select
               <Select
@@ -510,8 +518,8 @@ function AdminResources({
             </Form.Item>
             </Form.Item>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
           </Form>
           </Form>
-        </Card>
-        <Card title="迁移 API Token">
+        </SectionCard>
+        <SectionCard title="迁移 API Token">
           <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading}>
           <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading}>
             <Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
             <Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
               <Select
               <Select
@@ -528,9 +536,9 @@ function AdminResources({
             </Form.Item>
             </Form.Item>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
             <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
           </Form>
           </Form>
-        </Card>
+        </SectionCard>
       </div>
       </div>
-      <Card title="资源归属">
+      <SectionCard title="资源归属">
         <Table
         <Table
           rowKey={(group) => group.user.id}
           rowKey={(group) => group.user.id}
           columns={groupColumns}
           columns={groupColumns}
@@ -538,7 +546,7 @@ function AdminResources({
           loading={loading}
           loading={loading}
           expandable={{ expandedRowRender: renderResourceDetails }}
           expandable={{ expandedRowRender: renderResourceDetails }}
         />
         />
-      </Card>
+      </SectionCard>
     </Space>
     </Space>
   );
   );
 }
 }
@@ -640,7 +648,7 @@ function AdminMigration({
 
 
   return (
   return (
     <Space direction="vertical" size={16} className="full-width">
     <Space direction="vertical" size={16} className="full-width">
-      <Card title="合并预览">
+      <SectionCard title="合并预览">
         <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
         <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
           <Form.Item name="sourceUserId" label="源用户" rules={[{ required: true }]}>
           <Form.Item name="sourceUserId" label="源用户" rules={[{ required: true }]}>
             <Select options={userOptions} className="toolbar-select" />
             <Select options={userOptions} className="toolbar-select" />
@@ -650,16 +658,16 @@ function AdminMigration({
           </Form.Item>
           </Form.Item>
           <Button type="primary" htmlType="submit" loading={loading}>预览</Button>
           <Button type="primary" htmlType="submit" loading={loading}>预览</Button>
         </Form>
         </Form>
-      </Card>
+      </SectionCard>
       {preview ? (
       {preview ? (
-        <Card title={`${preview.sourceUser.username} → ${preview.targetUser.username}`}>
+        <SectionCard title={`${preview.sourceUser.username} → ${preview.targetUser.username}`}>
           <Space direction="vertical" size={16} className="full-width">
           <Space direction="vertical" size={16} className="full-width">
             {preview.warnings.length ? (
             {preview.warnings.length ? (
               <Alert type="warning" showIcon message={preview.warnings.map((item) => item.message || item.type).join(';')} />
               <Alert type="warning" showIcon message={preview.warnings.map((item) => item.message || item.type).join(';')} />
             ) : null}
             ) : null}
             <Space wrap>
             <Space wrap>
               {mergePreviewSummary(preview).map((item) => (
               {mergePreviewSummary(preview).map((item) => (
-                <Tag key={item.key}>{item.label}: {item.count}</Tag>
+                <StatusPill key={item.key} tone="neutral">{item.label}: {item.count}</StatusPill>
               ))}
               ))}
             </Space>
             </Space>
             <div className="form-grid two">
             <div className="form-grid two">
@@ -693,9 +701,9 @@ function AdminMigration({
               执行合并
               执行合并
             </Button>
             </Button>
           </Space>
           </Space>
-        </Card>
+        </SectionCard>
       ) : (
       ) : (
-        <Empty description="暂无预览" />
+        <EmptyState description="暂无预览" />
       )}
       )}
     </Space>
     </Space>
   );
   );
@@ -724,7 +732,7 @@ function AdminSystemEmail({
   }
   }
 
 
   return (
   return (
-    <Card title="系统邮件服务器">
+    <SectionCard title="系统邮件服务器">
       <Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
       <Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
         <div className="form-grid two">
         <div className="form-grid two">
           <Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
           <Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
@@ -760,7 +768,7 @@ function AdminSystemEmail({
           <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={loading}>发送测试</Button>
           <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={loading}>发送测试</Button>
         </Space>
         </Space>
       </Form>
       </Form>
-    </Card>
+    </SectionCard>
   );
   );
 }
 }
 
 
@@ -801,7 +809,7 @@ function AdminAuditLogs({
 
 
   return (
   return (
     <Space direction="vertical" size={16} className="full-width">
     <Space direction="vertical" size={16} className="full-width">
-      <Card>
+      <SectionCard className="admin-audit-toolbar-card">
         <Form form={form} layout="inline" onFinish={submit} disabled={loading}>
         <Form form={form} layout="inline" onFinish={submit} disabled={loading}>
           <Form.Item name="actorUserId" label="操作者">
           <Form.Item name="actorUserId" label="操作者">
             <Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
             <Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
@@ -820,7 +828,7 @@ function AdminAuditLogs({
           </Form.Item>
           </Form.Item>
           <Button type="primary" htmlType="submit" loading={loading}>查询</Button>
           <Button type="primary" htmlType="submit" loading={loading}>查询</Button>
         </Form>
         </Form>
-      </Card>
+      </SectionCard>
       <Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
       <Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
     </Space>
     </Space>
   );
   );
@@ -841,7 +849,16 @@ function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] })
 
 
 function UserStatusTag({ status }: { status: UserStatus }) {
 function UserStatusTag({ status }: { status: UserStatus }) {
   const meta = adminUserStatusMeta(status);
   const meta = adminUserStatusMeta(status);
-  return <Tag color={meta.color}>{meta.label}</Tag>;
+  const tone = statusToneFromColor(meta.color);
+  return <StatusPill tone={tone}>{meta.label}</StatusPill>;
+}
+
+function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
+  if (color === 'green' || color === 'success') return 'success';
+  if (color === 'gold' || color === 'orange' || color === 'warning') return 'warning';
+  if (color === 'red' || color === 'error' || color === 'volcano') return 'error';
+  if (color === 'blue' || color === 'processing' || color === 'cyan') return 'info';
+  return 'neutral';
 }
 }
 
 
 function formatDate(value?: string) {
 function formatDate(value?: string) {

+ 74 - 64
src/pages/ApiTokens.tsx

@@ -1,8 +1,12 @@
 import { CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
 import { CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
-import { Alert, Button, Card, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import { Alert, Button, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tooltip, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 import { useState } from 'react';
 import { useState } from 'react';
 
 
+import { CodeBlock } from '../components/common/CodeBlock';
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
 import {
 import {
   buildApiUsageExamples,
   buildApiUsageExamples,
   canCopyFullApiToken,
   canCopyFullApiToken,
@@ -68,57 +72,65 @@ export default function ApiTokens({ tokens, config, loading, onCreate, onDelete,
 
 
   return (
   return (
     <>
     <>
-      <Space direction="vertical" size={16} className="full-width">
-      <Card title={t('tokens.createTitle')}>
-        <Form form={form} layout="inline" onFinish={submit}>
-          <Form.Item name="name" rules={[{ required: true, message: t('tokens.nameRequired') }]}>
-            <Input placeholder={t('tokens.namePlaceholder')} />
-          </Form.Item>
-          <Button type="primary" htmlType="submit" loading={loading}>
-            {t('tokens.create')}
-          </Button>
-        </Form>
-      </Card>
-      <Card title={t('tokens.listTitle')} extra={<Tag>{tokens.length}</Tag>}>
-        <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
-        <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 900 }} />
-      </Card>
-      <Card title={t('tokens.docsTitle')}>
-        <Space direction="vertical" size={16} className="full-width">
-          <Alert type="info" showIcon message={t('tokens.noTokenHint')} />
-          <Descriptions column={1} bordered size="small">
-            <Descriptions.Item label={t('tokens.endpoint')}>{copyable(endpoint, onCopy)}</Descriptions.Item>
-            <Descriptions.Item label={t('tokens.authHeader')}>{copyable(t('tokens.authHeaderValue'), onCopy)}</Descriptions.Item>
-            <Descriptions.Item label={t('tokens.contentType')}><Typography.Text code>application/json</Typography.Text></Descriptions.Item>
-          </Descriptions>
-          <Card size="small" title={t('tokens.requestFields')}>
-            <ul className="api-doc-list">
-              <li>{t('tokens.fieldFrom')}</li>
-              <li>{t('tokens.fieldTo')}</li>
-              <li>{t('tokens.fieldSubject')}</li>
-              <li>{t('tokens.fieldText')}</li>
-              <li>{t('tokens.fieldHtml')}</li>
-            </ul>
-          </Card>
-          <Collapse
-            defaultActiveKey={['curl']}
-            items={[
-              { key: 'curl', label: t('tokens.curlExample'), children: <CodeSample value={examples.curl} /> },
-              { key: 'node', label: t('tokens.nodeExample'), children: <CodeSample value={examples.nodeFetch} /> },
-              { key: 'body', label: t('tokens.requestExample'), children: <CodeSample value={examples.requestBody} /> },
-              { key: 'response', label: t('tokens.responseExample'), children: <CodeSample value={examples.successResponse} /> }
-            ]}
-          />
-          <Card size="small" title={t('tokens.securityTips')}>
-            <ul className="api-doc-list">
-              <li>{t('tokens.securityTipStore')}</li>
-              <li>{t('tokens.securityTipRotate')}</li>
-              <li>{t('tokens.securityTipDomain')}</li>
-            </ul>
-          </Card>
-        </Space>
-      </Card>
-    </Space>
+      <Space direction="vertical" size={20} className="full-width">
+        <PageHeader title={t('nav.tokens')} />
+
+        <SectionCard title={t('tokens.createTitle')}>
+          <Form form={form} layout="inline" onFinish={submit}>
+            <Form.Item name="name" rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+              <Input placeholder={t('tokens.namePlaceholder')} />
+            </Form.Item>
+            <Button type="primary" htmlType="submit" loading={loading}>
+              {t('tokens.create')}
+            </Button>
+          </Form>
+        </SectionCard>
+
+        <SectionCard
+          title={t('tokens.listTitle')}
+          extra={<StatusPill tone="neutral">{tokens.length}</StatusPill>}
+        >
+          <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
+          <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 900 }} />
+        </SectionCard>
+
+        <SectionCard title={t('tokens.docsTitle')}>
+          <Space direction="vertical" size={16} className="full-width">
+            <Alert type="info" showIcon message={t('tokens.noTokenHint')} />
+            <Descriptions column={1} bordered size="small">
+              <Descriptions.Item label={t('tokens.endpoint')}>{copyable(endpoint, onCopy)}</Descriptions.Item>
+              <Descriptions.Item label={t('tokens.authHeader')}>{copyable(t('tokens.authHeaderValue'), onCopy)}</Descriptions.Item>
+              <Descriptions.Item label={t('tokens.contentType')}><Typography.Text code>application/json</Typography.Text></Descriptions.Item>
+            </Descriptions>
+            <SectionCard title={t('tokens.requestFields')}>
+              <ul className="api-doc-list">
+                <li>{t('tokens.fieldFrom')}</li>
+                <li>{t('tokens.fieldTo')}</li>
+                <li>{t('tokens.fieldSubject')}</li>
+                <li>{t('tokens.fieldText')}</li>
+                <li>{t('tokens.fieldHtml')}</li>
+              </ul>
+            </SectionCard>
+            <Collapse
+              defaultActiveKey={['curl']}
+              items={[
+                { key: 'curl', label: t('tokens.curlExample'), children: <CodeBlock value={examples.curl} onCopy={onCopy} /> },
+                { key: 'node', label: t('tokens.nodeExample'), children: <CodeBlock value={examples.nodeFetch} onCopy={onCopy} /> },
+                { key: 'body', label: t('tokens.requestExample'), children: <CodeBlock value={examples.requestBody} onCopy={onCopy} /> },
+                { key: 'response', label: t('tokens.responseExample'), children: <CodeBlock value={examples.successResponse} onCopy={onCopy} /> }
+              ]}
+            />
+            <SectionCard title={t('tokens.securityTips')}>
+              <ul className="api-doc-list">
+                <li>{t('tokens.securityTipStore')}</li>
+                <li>{t('tokens.securityTipRotate')}</li>
+                <li>{t('tokens.securityTipDomain')}</li>
+              </ul>
+            </SectionCard>
+          </Space>
+        </SectionCard>
+      </Space>
+
       <Modal
       <Modal
         title={t('tokens.createdTitle')}
         title={t('tokens.createdTitle')}
         open={Boolean(createdToken)}
         open={Boolean(createdToken)}
@@ -141,10 +153,16 @@ export default function ApiTokens({ tokens, config, loading, onCreate, onDelete,
         ]}
         ]}
       >
       >
         <Space direction="vertical" size={16} className="full-width">
         <Space direction="vertical" size={16} className="full-width">
-          <Alert type="warning" showIcon message={t('tokens.createdWarning')} />
-          <Typography.Paragraph code copyable className="code-sample">
-            {getCreatedApiTokenSecret(createdToken || {})}
-          </Typography.Paragraph>
+          <Alert
+            type="error"
+            showIcon
+            message={t('tokens.createdWarning')}
+            className="token-secret-alert"
+          />
+          <CodeBlock
+            value={getCreatedApiTokenSecret(createdToken || {}) || ''}
+            onCopy={onCopy}
+          />
         </Space>
         </Space>
       </Modal>
       </Modal>
     </>
     </>
@@ -159,11 +177,3 @@ function copyable(value: string, onCopy: (value: string) => void) {
     </Space>
     </Space>
   );
   );
 }
 }
-
-function CodeSample({ value }: { value: string }) {
-  return (
-    <Typography.Paragraph code copyable className="code-sample">
-      {value}
-    </Typography.Paragraph>
-  );
-}

+ 19 - 10
src/pages/DnsApi.tsx

@@ -1,8 +1,11 @@
 import { DeleteOutlined, EditOutlined, ThunderboltOutlined } from '@ant-design/icons';
 import { DeleteOutlined, EditOutlined, ThunderboltOutlined } from '@ant-design/icons';
-import { Button, Card, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
+import { Button, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 import { useState } from 'react';
 import { useState } from 'react';
 
 
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
 import { useI18n } from '../frontend/i18n/react';
 import type { DnsCredential } from '../frontend/types';
 import type { DnsCredential } from '../frontend/types';
 
 
@@ -58,11 +61,17 @@ export default function DnsApi({ credentials, loading, onSave, onTest, onDelete
   ];
   ];
 
 
   return (
   return (
-    <Space direction="vertical" size={16} className="full-width">
-      <Card title={t('dnsApi.title')} extra={<Tag>{credentials.length}</Tag>}>
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader title={t('dnsApi.title')} />
+
+      <SectionCard
+        title={t('dnsApi.title')}
+        extra={<StatusPill tone="neutral">{credentials.length}</StatusPill>}
+      >
         <Table rowKey="id" columns={columns} dataSource={credentials} scroll={{ x: 900 }} />
         <Table rowKey="id" columns={columns} dataSource={credentials} scroll={{ x: 900 }} />
-      </Card>
-      <Card
+      </SectionCard>
+
+      <SectionCard
         title={editing ? `${t('dnsApi.editTitle')} ${editing.name}` : t('dnsApi.createTitle')}
         title={editing ? `${t('dnsApi.editTitle')} ${editing.name}` : t('dnsApi.createTitle')}
         extra={editing ? <Button onClick={() => { setEditing(null); form.resetFields(); }}>{t('common.cancel')}</Button> : null}
         extra={editing ? <Button onClick={() => { setEditing(null); form.resetFields(); }}>{t('common.cancel')}</Button> : null}
       >
       >
@@ -134,15 +143,15 @@ export default function DnsApi({ credentials, loading, onSave, onTest, onDelete
             <Typography.Text type="secondary">{t('dnsApi.secretHint')}</Typography.Text>
             <Typography.Text type="secondary">{t('dnsApi.secretHint')}</Typography.Text>
           </Space>
           </Space>
         </Form>
         </Form>
-      </Card>
+      </SectionCard>
     </Space>
     </Space>
   );
   );
 }
 }
 
 
 function providerLabel(provider: string) {
 function providerLabel(provider: string) {
   return {
   return {
-    cloudflare: <Tag color="blue">Cloudflare</Tag>,
-    aliyun: <Tag color="orange">Aliyun DNS</Tag>,
-    dnspod: <Tag color="cyan">Tencent DNSPod</Tag>
-  }[provider] || <Tag>{provider}</Tag>;
+    cloudflare: <StatusPill tone="info">Cloudflare</StatusPill>,
+    aliyun: <StatusPill tone="warning">Aliyun DNS</StatusPill>,
+    dnspod: <StatusPill tone="neutral">Tencent DNSPod</StatusPill>
+  }[provider] || <StatusPill tone="neutral">{provider}</StatusPill>;
 }
 }

+ 10 - 4
src/pages/PlaceholderPage.tsx

@@ -1,12 +1,18 @@
-import { Card, Empty } from 'antd';
+import { Space } from 'antd';
 
 
+import { EmptyState } from '../components/common/EmptyState';
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
 import { useI18n } from '../frontend/i18n/react';
 import { useI18n } from '../frontend/i18n/react';
 
 
 export default function PlaceholderPage({ title }: { title: string }) {
 export default function PlaceholderPage({ title }: { title: string }) {
   const { t } = useI18n();
   const { t } = useI18n();
   return (
   return (
-    <Card>
-      <Empty description={`${title} ${t('domainDetail.placeholder')}`} />
-    </Card>
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader title={title} />
+      <SectionCard>
+        <EmptyState description={`${title} ${t('domainDetail.placeholder')}`} />
+      </SectionCard>
+    </Space>
   );
   );
 }
 }

+ 47 - 25
src/pages/SendingLogs.tsx

@@ -1,8 +1,12 @@
 import { CopyOutlined, SearchOutlined } from '@ant-design/icons';
 import { CopyOutlined, SearchOutlined } from '@ant-design/icons';
-import { Button, Card, DatePicker, Descriptions, Drawer, Empty, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
+import { Button, DatePicker, Descriptions, Drawer, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 import { useMemo, useState } from 'react';
 import { useMemo, useState } from 'react';
 
 
+import { EmptyState } from '../components/common/EmptyState';
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
 import { useI18n } from '../frontend/i18n/react';
 import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
 import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
 
 
@@ -38,7 +42,12 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', '), ellipsis: true },
     { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', '), ellipsis: true },
     { title: t('logs.domain'), dataIndex: 'domain', width: 180 },
     { title: t('logs.domain'), dataIndex: 'domain', width: 180 },
     { title: 'Subject', dataIndex: 'subject', ellipsis: true },
     { title: 'Subject', dataIndex: 'subject', ellipsis: true },
-    { title: t('common.status'), dataIndex: 'status', render: (value) => <StatusTag status={value} />, width: 120 },
+    {
+      title: t('common.status'),
+      dataIndex: 'status',
+      render: (value) => <StatusTag status={value} />,
+      width: 120
+    },
     { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
     { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
     { title: t('domains.actions'), render: (_, event) => <Button onClick={() => setSelected(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
     { title: t('domains.actions'), render: (_, event) => <Button onClick={() => setSelected(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
@@ -46,8 +55,10 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
 
 
   return (
   return (
     <>
     <>
-      <Space direction="vertical" size={16} className="full-width">
-        <Card>
+      <Space direction="vertical" size={20} className="full-width">
+        <PageHeader title={t('logs.title')} />
+
+        <SectionCard className="logs-toolbar-card">
           <div className="page-toolbar">
           <div className="page-toolbar">
             <Space wrap>
             <Space wrap>
               <RangePicker
               <RangePicker
@@ -89,11 +100,20 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
               />
               />
             </Space>
             </Space>
           </div>
           </div>
-        </Card>
-        <Card title={t('logs.title')}>
+        </SectionCard>
+
+        <SectionCard
+          title={t('logs.title')}
+          extra={
+            <Typography.Text type="secondary">
+              {filtered.length} / {events.length}
+            </Typography.Text>
+          }
+        >
           <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
           <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
-        </Card>
+        </SectionCard>
       </Space>
       </Space>
+
       <DeliveryLogDrawer
       <DeliveryLogDrawer
         event={selected}
         event={selected}
         onClose={() => setSelected(null)}
         onClose={() => setSelected(null)}
@@ -146,7 +166,7 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
                 <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
                 <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
               </Descriptions.Item>
               </Descriptions.Item>
             </Descriptions>
             </Descriptions>
-            <Card size="small" title={t('logs.deliveryAttempts')} className="delivery-log-card">
+            <SectionCard title={t('logs.deliveryAttempts')} className="delivery-log-card">
               {event.deliveryAttempts?.length ? (
               {event.deliveryAttempts?.length ? (
                 <Timeline
                 <Timeline
                   items={event.deliveryAttempts.map((attempt, index) => ({
                   items={event.deliveryAttempts.map((attempt, index) => ({
@@ -156,10 +176,10 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
                   }))}
                   }))}
                 />
                 />
               ) : (
               ) : (
-                <Empty description={t('logs.noDeliveryAttempts')} />
+                <EmptyState description={t('logs.noDeliveryAttempts')} />
               )}
               )}
-            </Card>
-            <Card size="small" title={t('logs.deliveryLog')} className="delivery-log-card">
+            </SectionCard>
+            <SectionCard title={t('logs.deliveryLog')} className="delivery-log-card">
               {deliveryLog.length ? (
               {deliveryLog.length ? (
                 <Timeline
                 <Timeline
                   items={deliveryLog.map((entry, index) => ({
                   items={deliveryLog.map((entry, index) => ({
@@ -169,9 +189,9 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
                   }))}
                   }))}
                 />
                 />
               ) : (
               ) : (
-                <Empty description={t('logs.noDeliveryLog')} />
+                <EmptyState description={t('logs.noDeliveryLog')} />
               )}
               )}
-            </Card>
+            </SectionCard>
           </Space>
           </Space>
         ) : null}
         ) : null}
       </Drawer>
       </Drawer>
@@ -184,7 +204,9 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
         <Space wrap size={8}>
         <Space wrap size={8}>
           <Typography.Text strong>{entry.phase}</Typography.Text>
           <Typography.Text strong>{entry.phase}</Typography.Text>
           {entry.direction ? <Tag>{entry.direction}</Tag> : null}
           {entry.direction ? <Tag>{entry.direction}</Tag> : null}
-          {entry.code ? <Tag color={entry.ok === false ? 'red' : 'blue'}>{entry.code}</Tag> : null}
+          {entry.code ? (
+            <StatusPill tone={entry.ok === false ? 'error' : 'info'}>{entry.code}</StatusPill>
+          ) : null}
           <Typography.Text type="secondary">{entry.at ? new Date(entry.at).toLocaleString() : '-'}</Typography.Text>
           <Typography.Text type="secondary">{entry.at ? new Date(entry.at).toLocaleString() : '-'}</Typography.Text>
         </Space>
         </Space>
         {entry.command ? <LogLine label="C" value={entry.command} /> : null}
         {entry.command ? <LogLine label="C" value={entry.command} /> : null}
@@ -261,15 +283,8 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     return lines.join('\n').trim();
     return lines.join('\n').trim();
   }
   }
 
 
-  function timelineColor(entry: DeliveryLogEntry) {
-    if (entry.ok === false || entry.phase === 'error') return 'red';
-    if (entry.phase === 'queue') return 'green';
-    if (entry.phase === 'auth') return 'gold';
-    return 'blue';
-  }
-
   function StatusTag({ status }: { status: string }) {
   function StatusTag({ status }: { status: string }) {
-    return <Tag color={statusColor(status)}>{statusLabel(status)}</Tag>;
+    return <StatusPill tone={statusTone(status)}>{statusLabel(status)}</StatusPill>;
   }
   }
 
 
   function statusLabel(status: string) {
   function statusLabel(status: string) {
@@ -282,14 +297,21 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     }[status] || status;
     }[status] || status;
   }
   }
 
 
-  function statusColor(status: string) {
+  function statusTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
     return {
     return {
-      queued: 'processing',
+      queued: 'info',
       sent: 'success',
       sent: 'success',
       deferred: 'warning',
       deferred: 'warning',
       bounced: 'error',
       bounced: 'error',
       failed: 'error'
       failed: 'error'
-    }[status] || 'default';
+    }[status] as 'success' | 'warning' | 'error' | 'info' | 'neutral' || 'neutral';
+  }
+
+  function timelineColor(entry: DeliveryLogEntry) {
+    if (entry.ok === false || entry.phase === 'error') return 'red';
+    if (entry.phase === 'queue') return 'green';
+    if (entry.phase === 'auth') return 'gold';
+    return 'blue';
   }
   }
 
 
   function deliveryAttemptColor(status: string) {
   function deliveryAttemptColor(status: string) {

+ 33 - 13
src/pages/Settings.tsx

@@ -1,6 +1,9 @@
-import { Button, Card, Form, Input, Select, Space, Switch, Table, Tag, Typography } from 'antd';
+import { Button, Form, Input, Select, Space, Switch, Table, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 
 
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
 import { StatusTag } from '../components/common/StatusTag';
 import { StatusTag } from '../components/common/StatusTag';
 import { getDnsCurrentValues } from '../frontend/domain-model.js';
 import { getDnsCurrentValues } from '../frontend/domain-model.js';
 import { useI18n } from '../frontend/i18n/react';
 import { useI18n } from '../frontend/i18n/react';
@@ -18,17 +21,30 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
   const { t } = useI18n();
   const { t } = useI18n();
   if (me?.role !== 'admin') {
   if (me?.role !== 'admin') {
     return (
     return (
-      <Card>
-        <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
-      </Card>
+      <Space direction="vertical" size={20} className="full-width">
+        <PageHeader title={t('nav.settings')} />
+        <SectionCard>
+          <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
+        </SectionCard>
+      </Space>
     );
     );
   }
   }
 
 
   const columns: ColumnsType<User> = [
   const columns: ColumnsType<User> = [
     { title: 'Username', dataIndex: 'username' },
     { title: 'Username', dataIndex: 'username' },
     { title: 'Email', dataIndex: 'email' },
     { title: 'Email', dataIndex: 'email' },
-    { title: 'Role', dataIndex: 'role', render: (value) => <Tag>{value}</Tag> },
-    { title: 'Status', dataIndex: 'status', render: (value) => <Tag color={value === 'active' ? 'success' : 'default'}>{value}</Tag> }
+    {
+      title: 'Role',
+      dataIndex: 'role',
+      render: (value) => <StatusPill tone="neutral">{value}</StatusPill>
+    },
+    {
+      title: 'Status',
+      dataIndex: 'status',
+      render: (value) => (
+        <StatusPill tone={value === 'active' ? 'success' : 'neutral'}>{value}</StatusPill>
+      )
+    }
   ];
   ];
   const checkColumns: ColumnsType<DnsRecord> = [
   const checkColumns: ColumnsType<DnsRecord> = [
     { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
     { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
@@ -53,8 +69,10 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
   const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : [];
   const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : [];
 
 
   return (
   return (
-    <Space direction="vertical" size={16} className="full-width">
-      <Card title="System">
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader title={t('nav.settings')} />
+
+      <SectionCard title="System">
         <Form
         <Form
           layout="vertical"
           layout="vertical"
           initialValues={settings || undefined}
           initialValues={settings || undefined}
@@ -88,8 +106,9 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
             {t('settings.save')}
             {t('settings.save')}
           </Button>
           </Button>
         </Form>
         </Form>
-      </Card>
-      <Card
+      </SectionCard>
+
+      <SectionCard
         title={t('settings.deliveryChecks')}
         title={t('settings.deliveryChecks')}
         extra={
         extra={
           settings?.systemChecks?.checkedAt
           settings?.systemChecks?.checkedAt
@@ -104,10 +123,11 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
           pagination={false}
           pagination={false}
           scroll={{ x: 940 }}
           scroll={{ x: 940 }}
         />
         />
-      </Card>
-      <Card title="Users">
+      </SectionCard>
+
+      <SectionCard title="Users">
         <Table rowKey="id" columns={columns} dataSource={users} />
         <Table rowKey="id" columns={columns} dataSource={users} />
-      </Card>
+      </SectionCard>
     </Space>
     </Space>
   );
   );
 }
 }

+ 35 - 12
src/pages/SmtpCredentials.tsx

@@ -1,8 +1,11 @@
 import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
-import { Button, Card, Descriptions, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Tag, Typography } from 'antd';
+import { Button, Descriptions, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import type { ColumnsType } from 'antd/es/table';
 import { useState } from 'react';
 import { useState } from 'react';
 
 
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { StatusPill } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
 import { useI18n } from '../frontend/i18n/react';
 import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
 import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
 
 
@@ -180,7 +183,11 @@ export default function SmtpCredentials({
       title: t('common.status'),
       title: t('common.status'),
       dataIndex: 'passwordSet',
       dataIndex: 'passwordSet',
       width: 120,
       width: 120,
-      render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}</Tag>
+      render: (value: boolean) => (
+        <StatusPill tone={value ? 'success' : 'neutral'}>
+          {value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}
+        </StatusPill>
+      )
     },
     },
     {
     {
       title: t('tokens.createdAt'),
       title: t('tokens.createdAt'),
@@ -211,7 +218,7 @@ export default function SmtpCredentials({
       render: (value, relay) => (
       render: (value, relay) => (
         <Space wrap>
         <Space wrap>
           <Typography.Text strong>{value}</Typography.Text>
           <Typography.Text strong>{value}</Typography.Text>
-          {relay.isDefault ? <Tag color="success">{t('smtpRelay.default')}</Tag> : null}
+          {relay.isDefault ? <StatusPill tone="success">{t('smtpRelay.default')}</StatusPill> : null}
         </Space>
         </Space>
       )
       )
     },
     },
@@ -230,7 +237,11 @@ export default function SmtpCredentials({
       title: t('smtpRelay.password'),
       title: t('smtpRelay.password'),
       dataIndex: 'passwordSet',
       dataIndex: 'passwordSet',
       width: 120,
       width: 120,
-      render: (value: boolean) => <Tag color={value ? 'success' : 'default'}>{value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}</Tag>
+      render: (value: boolean) => (
+        <StatusPill tone={value ? 'success' : 'neutral'}>
+          {value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}
+        </StatusPill>
+      )
     },
     },
     { title: 'HELO', dataIndex: 'helo', width: 180, render: (value) => value || '-' },
     { title: 'HELO', dataIndex: 'helo', width: 180, render: (value) => value || '-' },
     {
     {
@@ -249,12 +260,20 @@ export default function SmtpCredentials({
   ];
   ];
 
 
   return (
   return (
-    <Space direction="vertical" size={16} className="full-width">
-      <Card title={t('smtp.connectionTitle')}>
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader title={t('nav.smtp')} />
+
+      <SectionCard title={t('smtp.connectionTitle')}>
         <Descriptions column={1}>
         <Descriptions column={1}>
           <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
           <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
           <Descriptions.Item label="SMTP Port">
           <Descriptions.Item label="SMTP Port">
-            {(config?.submission?.ports || []).map((item) => <Tag key={item.port}>{item.port} · {item.protocol}</Tag>)}
+            <Space wrap size={8}>
+              {(config?.submission?.ports || []).map((item) => (
+                <StatusPill key={item.port} tone="info">
+                  {item.port} · {item.protocol}
+                </StatusPill>
+              ))}
+            </Space>
           </Descriptions.Item>
           </Descriptions.Item>
           <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
           <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
           <Descriptions.Item label={t('smtp.username')}>{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
           <Descriptions.Item label={t('smtp.username')}>{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
@@ -262,8 +281,9 @@ export default function SmtpCredentials({
             {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
             {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
           </Descriptions.Item>
           </Descriptions.Item>
         </Descriptions>
         </Descriptions>
-      </Card>
-      <Card
+      </SectionCard>
+
+      <SectionCard
         title={t('smtp.loginCredentialsTitle')}
         title={t('smtp.loginCredentialsTitle')}
         extra={
         extra={
           <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential}>
           <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential}>
@@ -278,8 +298,9 @@ export default function SmtpCredentials({
           scroll={{ x: 980 }}
           scroll={{ x: 980 }}
           pagination={credentials.length > 10 ? { pageSize: 10 } : false}
           pagination={credentials.length > 10 ? { pageSize: 10 } : false}
         />
         />
-      </Card>
-      <Card
+      </SectionCard>
+
+      <SectionCard
         title={t('smtpRelay.title')}
         title={t('smtpRelay.title')}
         extra={
         extra={
           <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay}>
           <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay}>
@@ -294,7 +315,8 @@ export default function SmtpCredentials({
           scroll={{ x: 1040 }}
           scroll={{ x: 1040 }}
           pagination={false}
           pagination={false}
         />
         />
-      </Card>
+      </SectionCard>
+
       <Modal
       <Modal
         title={editingCredential ? t('smtp.editTitle') : t('smtp.createTitle')}
         title={editingCredential ? t('smtp.editTitle') : t('smtp.createTitle')}
         open={credentialOpen}
         open={credentialOpen}
@@ -323,6 +345,7 @@ export default function SmtpCredentials({
           </Form.Item>
           </Form.Item>
         </Form>
         </Form>
       </Modal>
       </Modal>
+
       <Modal
       <Modal
         title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
         title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
         open={relayOpen}
         open={relayOpen}