feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
2
mengpost-frontend/.env.production
Normal file
2
mengpost-frontend/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产构建:npm run build 时注入。静态站点域名 post.smyhub.com,API 走 post.api.smyhub.com
|
||||
VITE_API_URL=https://post.api.smyhub.com
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<title>萌邮 MengPost · 多邮箱管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<title>萌邮 MengPost · 多邮箱管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3516
mengpost-frontend/package-lock.json
generated
3516
mengpost-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "mengpost-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "mengpost-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { tokenStore } from './api/client.js'
|
||||
import AccessGate from './components/AccessGate.jsx'
|
||||
import MainLayout from './components/MainLayout.jsx'
|
||||
import Topbar from './components/Topbar.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Admin from './pages/Admin.jsx'
|
||||
import Accounts from './pages/Accounts.jsx'
|
||||
import Mailbox from './pages/Mailbox.jsx'
|
||||
import Compose from './pages/Compose.jsx'
|
||||
|
||||
function useToken() {
|
||||
return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '')
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const token = useToken()
|
||||
if (!token) return <AccessGate />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar />
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin/accounts" element={<Accounts />} />
|
||||
<Route path="/admin/mailbox/:id" element={<Mailbox />} />
|
||||
<Route path="/admin/compose/:id" element={<Compose />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { tokenStore } from './api/client.js'
|
||||
import AccessGate from './components/AccessGate.jsx'
|
||||
import MainLayout from './components/MainLayout.jsx'
|
||||
import Topbar from './components/Topbar.jsx'
|
||||
import Home from './pages/Home.jsx'
|
||||
import Admin from './pages/Admin.jsx'
|
||||
import Accounts from './pages/Accounts.jsx'
|
||||
import Mailbox from './pages/Mailbox.jsx'
|
||||
import Compose from './pages/Compose.jsx'
|
||||
|
||||
function useToken() {
|
||||
return useSyncExternalStore(tokenStore.subscribe, () => tokenStore.get(), () => '')
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const token = useToken()
|
||||
if (!token) return <AccessGate />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar />
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin/accounts" element={<Accounts />} />
|
||||
<Route path="/admin/mailbox/:id" element={<Mailbox />} />
|
||||
<Route path="/admin/compose/:id" element={<Compose />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,109 +1,111 @@
|
||||
const TOKEN_KEY = 'mengpost_token'
|
||||
const EVT = 'mengpost-token'
|
||||
|
||||
/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */
|
||||
const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
export function apiUrl(path) {
|
||||
if (!path.startsWith('/')) path = '/' + path
|
||||
return base ? base + path : path
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => localStorage.getItem(TOKEN_KEY) || '',
|
||||
set: (t) => {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
clear: () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
subscribe: (fn) => {
|
||||
window.addEventListener(EVT, fn)
|
||||
return () => window.removeEventListener(EVT, fn)
|
||||
},
|
||||
}
|
||||
|
||||
function parseJsonSafe(txt) {
|
||||
if (!txt || !txt.trim()) return null
|
||||
try {
|
||||
return JSON.parse(txt)
|
||||
} catch {
|
||||
return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt }
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const t = tokenStore.get()
|
||||
if (t) headers['X-Auth-Token'] = t
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl(path), {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) || res.statusText || '请求失败'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 列表接口在空 body / SW 缓存异常时可能得到 null,统一成数组 */
|
||||
function asArray(v) {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
/** 校验 token 时不带 X-Auth-Token,避免旧 token 干扰 */
|
||||
export async function verifyToken(token) {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl('/api/auth/verify'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
if (data && data.ok === false) return { ok: false }
|
||||
throw new Error((data && data.error) || res.statusText || '验证失败')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAccounts: () => request('GET', '/api/accounts').then(asArray),
|
||||
createAccount: (a) => request('POST', '/api/accounts', a),
|
||||
updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a),
|
||||
deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`),
|
||||
listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray),
|
||||
listMessages: (id, mailbox = 'INBOX', limit = 30) =>
|
||||
request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then(
|
||||
asArray,
|
||||
),
|
||||
getMessage: (id, uid, mailbox = 'INBOX') =>
|
||||
request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`),
|
||||
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
|
||||
listSent: (id, limit = 50) =>
|
||||
request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray),
|
||||
}
|
||||
const TOKEN_KEY = 'mengpost_token'
|
||||
const EVT = 'mengpost-token'
|
||||
|
||||
/** 生产环境前后端分离时设 VITE_API_URL, 如 http://127.0.0.1:8787 */
|
||||
const base = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
export function apiUrl(path) {
|
||||
if (!path.startsWith('/')) path = '/' + path
|
||||
return base ? base + path : path
|
||||
}
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => localStorage.getItem(TOKEN_KEY) || '',
|
||||
set: (t) => {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
clear: () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new Event(EVT))
|
||||
},
|
||||
subscribe: (fn) => {
|
||||
window.addEventListener(EVT, fn)
|
||||
return () => window.removeEventListener(EVT, fn)
|
||||
},
|
||||
}
|
||||
|
||||
function parseJsonSafe(txt) {
|
||||
if (!txt || !txt.trim()) return null
|
||||
try {
|
||||
return JSON.parse(txt)
|
||||
} catch {
|
||||
return { error: txt.length > 120 ? txt.slice(0, 120) + '…' : txt }
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const t = tokenStore.get()
|
||||
if (t) headers['X-Auth-Token'] = t
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl(path), {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请确认已启动 mengpost-backend (默认 8787),且本页通过 Vite 开发服务器打开或已配置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) || res.statusText || '请求失败'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 列表接口在空 body / SW 缓存异常时可能得到 null,统一成数组 */
|
||||
function asArray(v) {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
/** 校验 token 时不带 X-Auth-Token,避免旧 token 干扰 */
|
||||
export async function verifyToken(token) {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(apiUrl('/api/auth/verify'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e.message?.includes('Failed to fetch')
|
||||
? '无法连接后端:请先运行 dev.bat / dev.sh 启动后端,或设置 VITE_API_URL'
|
||||
: e.message,
|
||||
)
|
||||
}
|
||||
const txt = await res.text()
|
||||
const data = parseJsonSafe(txt)
|
||||
if (!res.ok) {
|
||||
if (data && data.ok === false) return { ok: false }
|
||||
throw new Error((data && data.error) || res.statusText || '验证失败')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAccounts: () => request('GET', '/api/accounts').then(asArray),
|
||||
createAccount: (a) => request('POST', '/api/accounts', a),
|
||||
updateAccount: (id, a) => request('PUT', `/api/accounts/${id}`, a),
|
||||
deleteAccount: (id) => request('DELETE', `/api/accounts/${id}`),
|
||||
listMailboxes: (id) => request('GET', `/api/accounts/${id}/mailboxes`).then(asArray),
|
||||
listMessages: (id, mailbox = 'INBOX', limit = 30) =>
|
||||
request('GET', `/api/accounts/${id}/messages?mailbox=${encodeURIComponent(mailbox)}&limit=${limit}`).then(
|
||||
asArray,
|
||||
),
|
||||
getMessage: (id, uid, mailbox = 'INBOX') =>
|
||||
request('GET', `/api/accounts/${id}/messages/${uid}?mailbox=${encodeURIComponent(mailbox)}`),
|
||||
send: (id, body) => request('POST', `/api/accounts/${id}/send`, body),
|
||||
listSent: (id, limit = 50) =>
|
||||
request('GET', `/api/accounts/${id}/sent?limit=${limit}`).then(asArray),
|
||||
microsoftOAuthStart: () => request('GET', '/api/oauth/microsoft/start'),
|
||||
microsoftOAuthFinish: (state) => request('POST', '/api/oauth/microsoft/finish', { state }),
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { verifyToken, tokenStore } from '../api/client.js'
|
||||
|
||||
/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */
|
||||
export default function AccessGate() {
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = async () => {
|
||||
const v = key.trim()
|
||||
if (!v) return
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
try {
|
||||
const r = await verifyToken(v)
|
||||
if (r && r.ok) {
|
||||
tokenStore.set(v)
|
||||
} else {
|
||||
setErr('密钥不正确')
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e.message || '验证失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="access-gate">
|
||||
<div className="card" style={{ width: '100%', maxWidth: 360 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<img src="/logo.png" alt="" width={36} height={36} style={{ borderRadius: 8 }} />
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>萌邮 MengPost</h1>
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
请输入访问密钥(与后端 <code>MP_TOKEN</code> 一致)
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="访问密钥"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 13, marginTop: 10 }}>{err}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<button type="button" className="btn-primary" disabled={loading} onClick={submit}>
|
||||
{loading ? '验证中…' : '进入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { verifyToken, tokenStore } from '../api/client.js'
|
||||
|
||||
/** 未携带有效密钥时全屏展示, 校验通过后写入 localStorage 并刷新应用状态 */
|
||||
export default function AccessGate() {
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = async () => {
|
||||
const v = key.trim()
|
||||
if (!v) return
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
try {
|
||||
const r = await verifyToken(v)
|
||||
if (r && r.ok) {
|
||||
tokenStore.set(v)
|
||||
} else {
|
||||
setErr('密钥不正确')
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e.message || '验证失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="access-gate">
|
||||
<div className="card" style={{ width: '100%', maxWidth: 360 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<img src="/logo.png" alt="" width={36} height={36} style={{ borderRadius: 8 }} />
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>萌邮 MengPost</h1>
|
||||
</div>
|
||||
<p className="muted" style={{ marginBottom: 16, fontSize: 14 }}>
|
||||
请输入访问密钥(与后端 <code>MP_TOKEN</code> 一致)
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="访问密钥"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 13, marginTop: 10 }}>{err}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<button type="button" className="btn-primary" disabled={loading} onClick={submit}>
|
||||
{loading ? '验证中…' : '进入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,99 +1,184 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
function useMailboxAccountId() {
|
||||
const loc = useLocation()
|
||||
return useMemo(() => {
|
||||
const m = loc.pathname.match(/\/admin\/mailbox\/(\d+)/)
|
||||
return m ? Number(m[1]) : null
|
||||
}, [loc.pathname])
|
||||
}
|
||||
|
||||
function accountActive(location, id) {
|
||||
return (
|
||||
location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMailboxLink({ accountId, name }) {
|
||||
const location = useLocation()
|
||||
const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX'
|
||||
const active =
|
||||
location.pathname === `/admin/mailbox/${accountId}` && current === name
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/mailbox/${accountId}?mailbox=${encodeURIComponent(name)}`}
|
||||
className={'sidebar-item' + (active ? ' active' : '')}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const location = useLocation()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [boxes, setBoxes] = useState([])
|
||||
const accountId = useMailboxAccountId()
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setAccounts([]))
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setBoxes([])
|
||||
return
|
||||
}
|
||||
api
|
||||
.listMailboxes(accountId)
|
||||
.then((b) => setBoxes(Array.isArray(b) ? b : []))
|
||||
.catch(() => setBoxes([]))
|
||||
}, [accountId])
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label">邮箱</div>
|
||||
{accounts.map((a) => (
|
||||
<NavLink
|
||||
key={a.id}
|
||||
to={`/admin/mailbox/${a.id}`}
|
||||
className={() =>
|
||||
'sidebar-item' + (accountActive(location, a.id) ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<span className="sidebar-item-title">{a.name || '未命名'}</span>
|
||||
<span className="sidebar-item-sub muted">{a.email}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
|
||||
<Link to="/admin/accounts" className="sidebar-foot">
|
||||
管理账户
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{accountId != null && (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label">邮件夹</div>
|
||||
{boxes.map((b) => (
|
||||
<SidebarMailboxLink key={b} accountId={accountId} name={b} />
|
||||
))}
|
||||
{boxes.length === 0 && <p className="sidebar-muted">暂无或加载中</p>}
|
||||
<Link to={`/admin/compose/${accountId}`} className="sidebar-foot">
|
||||
写邮件
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<main className="main-area">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
function emailDomain(email) {
|
||||
const s = (email || '').trim().toLowerCase()
|
||||
const at = s.lastIndexOf('@')
|
||||
if (at < 0 || at === s.length - 1) return '其他'
|
||||
return s.slice(at + 1)
|
||||
}
|
||||
|
||||
function groupAccountsByDomain(accounts) {
|
||||
const map = new Map()
|
||||
for (const a of accounts) {
|
||||
const d = emailDomain(a.email)
|
||||
if (!map.has(d)) map.set(d, [])
|
||||
map.get(d).push(a)
|
||||
}
|
||||
const groups = Array.from(map.entries()).map(([domain, items]) => ({
|
||||
domain,
|
||||
items: items.slice().sort((x, y) => (x.email || '').localeCompare(y.email || '', 'zh-CN')),
|
||||
}))
|
||||
groups.sort((a, b) => a.domain.localeCompare(b.domain, 'zh-CN'))
|
||||
return groups
|
||||
}
|
||||
|
||||
function useMailboxAccountId() {
|
||||
const loc = useLocation()
|
||||
return useMemo(() => {
|
||||
const m = loc.pathname.match(/\/admin\/(?:mailbox|compose)\/(\d+)/)
|
||||
return m ? Number(m[1]) : null
|
||||
}, [loc.pathname])
|
||||
}
|
||||
|
||||
function accountActive(location, id) {
|
||||
return (
|
||||
location.pathname === `/admin/mailbox/${id}` || location.pathname === `/admin/compose/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMailboxLink({ accountId, name }) {
|
||||
const location = useLocation()
|
||||
const current = new URLSearchParams(location.search).get('mailbox') || 'INBOX'
|
||||
const active =
|
||||
location.pathname === `/admin/mailbox/${accountId}` && current === name
|
||||
return (
|
||||
<Link
|
||||
to={`/admin/mailbox/${accountId}?mailbox=${encodeURIComponent(name)}`}
|
||||
className={'sidebar-item' + (active ? ' active' : '')}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const location = useLocation()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [boxes, setBoxes] = useState([])
|
||||
const [domainCollapsed, setDomainCollapsed] = useState({})
|
||||
const accountId = useMailboxAccountId()
|
||||
|
||||
const groupedAccounts = useMemo(() => groupAccountsByDomain(accounts), [accounts])
|
||||
|
||||
const expandAllDomains = useCallback(() => setDomainCollapsed({}), [])
|
||||
const collapseAllDomains = useCallback(() => {
|
||||
const next = {}
|
||||
for (const g of groupedAccounts) next[g.domain] = true
|
||||
setDomainCollapsed(next)
|
||||
}, [groupedAccounts])
|
||||
|
||||
const toggleDomain = useCallback((domain) => {
|
||||
setDomainCollapsed((c) => ({ ...c, [domain]: !c[domain] }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setAccounts([]))
|
||||
}, [location.pathname])
|
||||
|
||||
// 当前选中的账户所在分组自动展开
|
||||
useEffect(() => {
|
||||
const active = accounts.find((a) => accountActive(location, a.id))
|
||||
if (!active) return
|
||||
const d = emailDomain(active.email)
|
||||
setDomainCollapsed((c) => {
|
||||
if (!c[d]) return c
|
||||
const next = { ...c }
|
||||
delete next[d]
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, accounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setBoxes([])
|
||||
return
|
||||
}
|
||||
api
|
||||
.listMailboxes(accountId)
|
||||
.then((b) => setBoxes(Array.isArray(b) ? b : []))
|
||||
.catch(() => setBoxes([]))
|
||||
}, [accountId])
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label-row">
|
||||
<div className="sidebar-label">邮箱</div>
|
||||
{accounts.length > 0 && (
|
||||
<div className="sidebar-label-actions">
|
||||
<button type="button" className="sidebar-mini-btn" onClick={expandAllDomains}>
|
||||
全部展开
|
||||
</button>
|
||||
<button type="button" className="sidebar-mini-btn" onClick={collapseAllDomains}>
|
||||
全部收起
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{groupedAccounts.map(({ domain, items }) => {
|
||||
const collapsed = !!domainCollapsed[domain]
|
||||
return (
|
||||
<div key={domain} className="sidebar-group">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-group-header"
|
||||
onClick={() => toggleDomain(domain)}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<span className="sidebar-group-chevron" aria-hidden>
|
||||
{collapsed ? '\u25B8' : '\u25BE'}
|
||||
</span>
|
||||
<span className="sidebar-group-title">@{domain}</span>
|
||||
<span className="sidebar-group-count">{items.length}</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-body">
|
||||
{items.map((a) => (
|
||||
<NavLink
|
||||
key={a.id}
|
||||
to={`/admin/mailbox/${a.id}`}
|
||||
className={() =>
|
||||
'sidebar-item' + (accountActive(location, a.id) ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<span className="sidebar-item-title">{a.name || '未命名'}</span>
|
||||
<span className="sidebar-item-sub muted">{a.email}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{accounts.length === 0 && <p className="sidebar-muted">暂无账户</p>}
|
||||
<Link to="/admin/accounts" className="sidebar-foot">
|
||||
管理账户
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-area">
|
||||
<Outlet />
|
||||
</main>
|
||||
{accountId != null && (
|
||||
<aside className="sidebar sidebar-right" aria-label="邮件夹">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label">邮件夹</div>
|
||||
{boxes.map((b) => (
|
||||
<SidebarMailboxLink key={b} accountId={accountId} name={b} />
|
||||
))}
|
||||
{boxes.length === 0 && <p className="sidebar-muted">暂无或加载中</p>}
|
||||
<Link to={`/admin/compose/${accountId}`} className="sidebar-foot">
|
||||
写邮件
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { tokenStore } from '../api/client.js'
|
||||
|
||||
export default function Topbar() {
|
||||
const nav = useNavigate()
|
||||
|
||||
const logout = () => {
|
||||
tokenStore.clear()
|
||||
nav('/', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<NavLink to="/admin" className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<img className="logo" src="/logo.png" alt="" width={28} height={28} />
|
||||
<span>萌邮 MengPost</span>
|
||||
</NavLink>
|
||||
<nav>
|
||||
<NavLink to="/admin" end>
|
||||
概览
|
||||
</NavLink>
|
||||
<NavLink to="/admin/accounts">账户</NavLink>
|
||||
<a onClick={logout} style={{ cursor: 'pointer' }}>
|
||||
登出
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { tokenStore } from '../api/client.js'
|
||||
|
||||
export default function Topbar() {
|
||||
const nav = useNavigate()
|
||||
|
||||
const logout = () => {
|
||||
tokenStore.clear()
|
||||
nav('/', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<NavLink to="/admin" className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<img className="logo" src="/logo.png" alt="" width={28} height={28} />
|
||||
<span>萌邮 MengPost</span>
|
||||
</NavLink>
|
||||
<nav>
|
||||
<NavLink to="/admin" end>
|
||||
概览
|
||||
</NavLink>
|
||||
<NavLink to="/admin/accounts">账户</NavLink>
|
||||
<a onClick={logout} style={{ cursor: 'pointer' }}>
|
||||
登出
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,225 +1,311 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
const empty = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
use_tls: true,
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [list, setList] = useState([])
|
||||
const [form, setForm] = useState(empty)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setList(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (a) => {
|
||||
setEditing(a.id)
|
||||
setForm({ ...a, password: '' })
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeModal()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [modalOpen, closeModal])
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateAccount(editing, form)
|
||||
setMsg('已更新')
|
||||
} else {
|
||||
await api.createAccount(form)
|
||||
setMsg('已创建')
|
||||
}
|
||||
closeModal()
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('确定删除该账户?')) return
|
||||
try {
|
||||
await api.deleteAccount(id)
|
||||
setMsg('已删除')
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v =
|
||||
e.target.type === 'checkbox'
|
||||
? e.target.checked
|
||||
: e.target.type === 'number'
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">邮箱账户</h1>
|
||||
<button type="button" className="btn-primary" onClick={openNew}>
|
||||
新增账户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
|
||||
{err && !modalOpen && (
|
||||
<p style={{ color: 'var(--danger)', margin: '0 0 12px' }}>{err}</p>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>已有账户</h2>
|
||||
{list.length === 0 ? (
|
||||
<p className="muted">暂无账户。</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th>SMTP</th>
|
||||
<th>IMAP</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>
|
||||
{a.smtp_host}:{a.smtp_port}
|
||||
</td>
|
||||
<td>
|
||||
{a.imap_host}:{a.imap_port}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => openEdit(a)}>
|
||||
编辑
|
||||
</button>{' '}
|
||||
<button type="button" className="btn-danger" onClick={() => del(a.id)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="presentation"
|
||||
onClick={(e) => e.target === e.currentTarget && closeModal()}
|
||||
>
|
||||
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2>
|
||||
<form onSubmit={save}>
|
||||
<div className="form-row">
|
||||
<label>名称</label>
|
||||
<input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>邮箱</label>
|
||||
<input value={form.email} onChange={set('email')} type="email" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>密码 / 授权码</label>
|
||||
<input
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
type="password"
|
||||
placeholder={editing ? '留空不修改' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 主机</label>
|
||||
<input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 端口</label>
|
||||
<input value={form.smtp_port} onChange={set('smtp_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 主机</label>
|
||||
<input value={form.imap_host} onChange={set('imap_host')} placeholder="imap.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 端口</label>
|
||||
<input value={form.imap_port} onChange={set('imap_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>使用 TLS/SSL</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.use_tls} onChange={set('use_tls')} style={{ width: 'auto' }} />
|
||||
<span className="muted">建议开启</span>
|
||||
</label>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', marginTop: 8, fontSize: 14 }}>{err}</div>
|
||||
)}
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button type="button" onClick={closeModal}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
const empty = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
use_tls: true,
|
||||
}
|
||||
|
||||
function authLabel(t) {
|
||||
return t === 'oauth_microsoft' ? 'OAuth(微软)' : '密码'
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [list, setList] = useState([])
|
||||
const [form, setForm] = useState(empty)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setList(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ms = searchParams.get('oauth_ms')
|
||||
const oe = searchParams.get('oauth_err')
|
||||
if (!ms && !oe) return
|
||||
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (oe) {
|
||||
setErr(decodeURIComponent(oe.replace(/\+/g, ' ')))
|
||||
next.delete('oauth_err')
|
||||
}
|
||||
if (ms) {
|
||||
next.delete('oauth_ms')
|
||||
}
|
||||
setSearchParams(next, { replace: true })
|
||||
|
||||
if (ms) {
|
||||
const gate = `mp_ms_oauth_${ms}`
|
||||
let skip = false
|
||||
try {
|
||||
if (sessionStorage.getItem(gate)) skip = true
|
||||
else sessionStorage.setItem(gate, '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (skip) return
|
||||
api
|
||||
.microsoftOAuthFinish(ms)
|
||||
.then(() => {
|
||||
setMsg('微软账户已通过 OAuth 连接')
|
||||
reload()
|
||||
})
|
||||
.catch((e) => {
|
||||
try {
|
||||
sessionStorage.removeItem(gate)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setErr(e.message)
|
||||
})
|
||||
}
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const startMicrosoftOAuth = async () => {
|
||||
setErr('')
|
||||
setMsg('')
|
||||
try {
|
||||
const { url } = await api.microsoftOAuthStart()
|
||||
if (url) window.location.href = url
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (a) => {
|
||||
setEditing(a.id)
|
||||
setForm({ ...a, password: '' })
|
||||
setErr('')
|
||||
setMsg('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
setForm(empty)
|
||||
setErr('')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeModal()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [modalOpen, closeModal])
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr('')
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateAccount(editing, form)
|
||||
setMsg('已更新')
|
||||
} else {
|
||||
await api.createAccount(form)
|
||||
setMsg('已创建')
|
||||
}
|
||||
closeModal()
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('确定删除该账户?')) return
|
||||
try {
|
||||
await api.deleteAccount(id)
|
||||
setMsg('已删除')
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v =
|
||||
e.target.type === 'checkbox'
|
||||
? e.target.checked
|
||||
: e.target.type === 'number'
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">邮箱账户</h1>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<button type="button" className="btn-primary" onClick={startMicrosoftOAuth}>
|
||||
微软邮箱 OAuth 登录
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={openNew}>
|
||||
新增账户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <p style={{ color: '#16a34a', margin: '0 0 12px' }}>{msg}</p>}
|
||||
{err && !modalOpen && (
|
||||
<p style={{ color: 'var(--danger)', margin: '0 0 12px' }}>{err}</p>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>已有账户</h2>
|
||||
{list.length === 0 ? (
|
||||
<p className="muted">暂无账户。</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th>认证</th>
|
||||
<th>SMTP</th>
|
||||
<th>IMAP</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>{authLabel(a.auth_type)}</td>
|
||||
<td>
|
||||
{a.smtp_host}:{a.smtp_port}
|
||||
</td>
|
||||
<td>
|
||||
{a.imap_host}:{a.imap_port}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => openEdit(a)}>
|
||||
编辑
|
||||
</button>{' '}
|
||||
<button type="button" className="btn-danger" onClick={() => del(a.id)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="presentation"
|
||||
onClick={(e) => e.target === e.currentTarget && closeModal()}
|
||||
>
|
||||
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{editing ? `编辑账户 #${editing}` : '新增账户'}</h2>
|
||||
<form onSubmit={save}>
|
||||
<div className="form-row">
|
||||
<label>快速配置</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
use_tls: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
清空主机(手动填)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>名称</label>
|
||||
<input value={form.name} onChange={set('name')} placeholder="工作 / 私人" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>邮箱</label>
|
||||
<input value={form.email} onChange={set('email')} type="email" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>密码 / 授权码</label>
|
||||
<input
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
type="password"
|
||||
placeholder={editing ? '留空不修改' : '各邮箱授权码或密码'}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 主机</label>
|
||||
<input value={form.smtp_host} onChange={set('smtp_host')} placeholder="smtp.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>SMTP 端口</label>
|
||||
<input value={form.smtp_port} onChange={set('smtp_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 主机</label>
|
||||
<input value={form.imap_host} onChange={set('imap_host')} placeholder="imap.example.com" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>IMAP 端口</label>
|
||||
<input value={form.imap_port} onChange={set('imap_port')} type="number" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>使用 TLS/SSL</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.use_tls} onChange={set('use_tls')} style={{ width: 'auto' }} />
|
||||
<span className="muted">建议开启</span>
|
||||
</label>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ color: 'var(--danger)', marginTop: 8, fontSize: 14 }}>{err}</div>
|
||||
)}
|
||||
<div className="actions" style={{ marginTop: 16 }}>
|
||||
<button type="button" onClick={closeModal}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Admin() {
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">概览</h1>
|
||||
<p className="muted">共 {accounts.length} 个邮箱账户</p>
|
||||
|
||||
{err && (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{accounts.length === 0 ? (
|
||||
<p className="muted">
|
||||
暂无账户,前往 <Link to="/admin/accounts">管理账户</Link> 添加。
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>
|
||||
<Link to={`/admin/mailbox/${a.id}`}>打开</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Admin() {
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">概览</h1>
|
||||
<p className="muted">共 {accounts.length} 个邮箱账户</p>
|
||||
|
||||
{err && (
|
||||
<div className="card" style={{ color: 'var(--danger)' }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{accounts.length === 0 ? (
|
||||
<p className="muted">
|
||||
暂无账户,前往 <Link to="/admin/accounts">管理账户</Link> 添加。
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.email}</td>
|
||||
<td>
|
||||
<Link to={`/admin/mailbox/${a.id}`}>打开</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Compose() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const nav = useNavigate()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [form, setForm] = useState({ to: '', subject: '', body: '', html: false })
|
||||
const [sending, setSending] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
const send = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr(''); setMsg(''); setSending(true)
|
||||
try {
|
||||
await api.send(accountId, form)
|
||||
setMsg('发送成功')
|
||||
setForm({ to: '', subject: '', body: '', html: false })
|
||||
} catch (e) { setErr(e.message) }
|
||||
finally { setSending(false) }
|
||||
}
|
||||
|
||||
const acc = accounts.find((a) => a.id === accountId)
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">撰写</h1>
|
||||
<p className="muted">{acc ? acc.email : `#${accountId}`}</p>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={send}>
|
||||
<div className="form-row"><label>切换账户</label>
|
||||
<select value={accountId} onChange={(e) => nav(`/admin/compose/${e.target.value}`)}>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} <{a.email}></option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label>收件人</label><input value={form.to} onChange={set('to')} type="email" required /></div>
|
||||
<div className="form-row"><label>主题</label><input value={form.subject} onChange={set('subject')} required /></div>
|
||||
<div className="form-row">
|
||||
<label>HTML</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.html} onChange={set('html')} style={{ width: 'auto' }} />
|
||||
<span className="muted">按 HTML 发送</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'start' }}>
|
||||
<label>正文</label>
|
||||
<textarea value={form.body} onChange={set('body')} rows={10} required />
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button type="submit" className="btn-primary" disabled={sending}>
|
||||
{sending ? '发送中…' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>}
|
||||
{msg && <div style={{ color: '#16a34a', marginTop: 8 }}>{msg}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Compose() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const nav = useNavigate()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [form, setForm] = useState({ to: '', subject: '', body: '', html: false })
|
||||
const [sending, setSending] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAccounts()
|
||||
.then((rows) => setAccounts(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const set = (k) => (e) => {
|
||||
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value
|
||||
setForm({ ...form, [k]: v })
|
||||
}
|
||||
|
||||
const send = async (e) => {
|
||||
e.preventDefault()
|
||||
setErr(''); setMsg(''); setSending(true)
|
||||
try {
|
||||
await api.send(accountId, form)
|
||||
setMsg('发送成功')
|
||||
setForm({ to: '', subject: '', body: '', html: false })
|
||||
} catch (e) { setErr(e.message) }
|
||||
finally { setSending(false) }
|
||||
}
|
||||
|
||||
const acc = accounts.find((a) => a.id === accountId)
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<h1 className="page-title">撰写</h1>
|
||||
<p className="muted">{acc ? acc.email : `#${accountId}`}</p>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={send}>
|
||||
<div className="form-row"><label>切换账户</label>
|
||||
<select value={accountId} onChange={(e) => nav(`/admin/compose/${e.target.value}`)}>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} <{a.email}></option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label>收件人</label><input value={form.to} onChange={set('to')} type="email" required /></div>
|
||||
<div className="form-row"><label>主题</label><input value={form.subject} onChange={set('subject')} required /></div>
|
||||
<div className="form-row">
|
||||
<label>HTML</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="checkbox" checked={form.html} onChange={set('html')} style={{ width: 'auto' }} />
|
||||
<span className="muted">按 HTML 发送</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'start' }}>
|
||||
<label>正文</label>
|
||||
<textarea value={form.body} onChange={set('body')} rows={10} required />
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button type="submit" className="btn-primary" disabled={sending}>
|
||||
{sending ? '发送中…' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>}
|
||||
{msg && <div style={{ color: '#16a34a', marginTop: 8 }}>{msg}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export default function Home() {
|
||||
return <Navigate to="/admin" replace />
|
||||
}
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export default function Home() {
|
||||
return <Navigate to="/admin" replace />
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Mailbox() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const [searchParams] = useSearchParams()
|
||||
const mailbox = searchParams.get('mailbox') || 'INBOX'
|
||||
|
||||
const [list, setList] = useState([])
|
||||
const [active, setActive] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setActive(null)
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
api
|
||||
.listMessages(accountId, mailbox)
|
||||
.then((l) => setList(l || []))
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [accountId, mailbox])
|
||||
|
||||
const closeDetail = useCallback(() => setActive(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeDetail()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [active, closeDetail])
|
||||
|
||||
const open = async (m) => {
|
||||
setActive({ ...m, loading: true })
|
||||
try {
|
||||
const d = await api.getMessage(accountId, m.uid, mailbox)
|
||||
setActive(d)
|
||||
} catch (e) {
|
||||
setActive({ ...m, error: e.message, loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">{mailbox}</h1>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{loading && <p className="muted" style={{ padding: 12 }}>加载中…</p>}
|
||||
{err && <p style={{ color: 'var(--danger)', padding: 12 }}>{err}</p>}
|
||||
{!loading && !err && list.length === 0 && (
|
||||
<p className="muted" style={{ padding: 12 }}>暂无邮件</p>
|
||||
)}
|
||||
<ul className="mail-list">
|
||||
{list.map((m) => (
|
||||
<li key={m.uid} className={m.seen ? '' : 'unseen'} onClick={() => open(m)}>
|
||||
<div className="subject">{m.subject || '(无主题)'}</div>
|
||||
<div className="from">
|
||||
{m.from} · {m.date && new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mail-subject"
|
||||
onClick={(e) => e.target === e.currentTarget && closeDetail()}
|
||||
>
|
||||
<div className="modal modal-mail" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-mail-toolbar">
|
||||
<h2 id="mail-subject">{active.subject || '(无主题)'}</h2>
|
||||
<button type="button" onClick={closeDetail}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-mail-meta muted">
|
||||
<p>发件人: {active.from || '—'}</p>
|
||||
{active.to && active.to.length > 0 && (
|
||||
<p>收件人: {active.to.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-mail-body">
|
||||
{active.loading && <p className="muted">加载中…</p>}
|
||||
{active.error && <p style={{ color: 'var(--danger)' }}>{active.error}</p>}
|
||||
{!active.loading && !active.error && active.html && (
|
||||
<iframe title="mail-html" srcDoc={active.html} />
|
||||
)}
|
||||
{!active.loading && !active.error && !active.html && (
|
||||
<pre>{active.text || ''}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
export default function Mailbox() {
|
||||
const { id } = useParams()
|
||||
const accountId = Number(id)
|
||||
const [searchParams] = useSearchParams()
|
||||
const mailbox = searchParams.get('mailbox') || 'INBOX'
|
||||
|
||||
const [list, setList] = useState([])
|
||||
const [active, setActive] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setActive(null)
|
||||
setLoading(true)
|
||||
setErr('')
|
||||
api
|
||||
.listMessages(accountId, mailbox)
|
||||
.then((l) => setList(l || []))
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [accountId, mailbox])
|
||||
|
||||
const closeDetail = useCallback(() => setActive(null), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') closeDetail()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [active, closeDetail])
|
||||
|
||||
const open = async (m) => {
|
||||
setActive({ ...m, loading: true })
|
||||
try {
|
||||
const d = await api.getMessage(accountId, m.uid, mailbox)
|
||||
setActive(d)
|
||||
} catch (e) {
|
||||
setActive({ ...m, error: e.message, loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-inner">
|
||||
<div className="page-toolbar">
|
||||
<h1 className="page-title">{mailbox}</h1>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{loading && <p className="muted" style={{ padding: 12 }}>加载中…</p>}
|
||||
{err && <p style={{ color: 'var(--danger)', padding: 12 }}>{err}</p>}
|
||||
{!loading && !err && list.length === 0 && (
|
||||
<p className="muted" style={{ padding: 12 }}>暂无邮件</p>
|
||||
)}
|
||||
<ul className="mail-list">
|
||||
{list.map((m) => (
|
||||
<li key={m.uid} className={m.seen ? '' : 'unseen'} onClick={() => open(m)}>
|
||||
<div className="subject">{m.subject || '(无主题)'}</div>
|
||||
<div className="from">
|
||||
{m.from} · {m.date && new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div
|
||||
className="modal-mask"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mail-subject"
|
||||
onClick={(e) => e.target === e.currentTarget && closeDetail()}
|
||||
>
|
||||
<div className="modal modal-mail" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-mail-toolbar">
|
||||
<h2 id="mail-subject">{active.subject || '(无主题)'}</h2>
|
||||
<button type="button" onClick={closeDetail}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-mail-meta muted">
|
||||
<p>发件人: {active.from || '—'}</p>
|
||||
{active.to && active.to.length > 0 && (
|
||||
<p>收件人: {active.to.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-mail-body">
|
||||
{active.loading && <p className="muted">加载中…</p>}
|
||||
{active.error && <p style={{ color: 'var(--danger)' }}>{active.error}</p>}
|
||||
{!active.loading && !active.error && active.html && (
|
||||
<iframe title="mail-html" srcDoc={active.html} />
|
||||
)}
|
||||
{!active.loading && !active.error && !active.html && (
|
||||
<pre>{active.text || ''}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,363 +1,465 @@
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--radius: 6px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
button,
|
||||
.btn {
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
button:hover,
|
||||
.btn:hover { border-color: #c5c9d0; background: #f3f4f6; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
.btn-danger { color: var(--danger); border-color: var(--border); background: var(--surface); }
|
||||
.btn-danger:hover { background: #fef2f2; border-color: #fca5a5; }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .15);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 54px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 0 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: #f3f4f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: #eff6ff;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-item-title { font-weight: 500; }
|
||||
.sidebar-item-sub { font-size: 12px; word-break: break-all; }
|
||||
|
||||
.sidebar-muted {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sidebar-foot:hover { color: var(--accent); }
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 主内容区占满侧栏右侧可用宽度(不再限制 900px) */
|
||||
.main-inner {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px 28px 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: none;
|
||||
}
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.sidebar-label { width: 100%; margin-bottom: 0; }
|
||||
.sidebar-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; }
|
||||
.sidebar-foot { width: 100%; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 54px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topbar .brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
cursor: pointer; user-select: none; font-weight: 600;
|
||||
}
|
||||
.topbar .logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar nav { margin-left: auto; display: flex; gap: 14px; }
|
||||
.topbar nav a { color: var(--muted); }
|
||||
.topbar nav a.active { color: var(--text); font-weight: 600; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0 0 12px; font-weight: 600; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; color: var(--muted); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; background: #fafbfc; }
|
||||
tr:hover td { background: #f9fafb; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; align-items: center; margin-bottom: 10px; }
|
||||
@media (max-width: 640px) {
|
||||
.form-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.topbar nav { gap: 10px; }
|
||||
.container { padding: 16px 12px 48px; }
|
||||
th:nth-child(3), td:nth-child(3),
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 260px 1fr; gap: 16px; }
|
||||
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.mail-list { list-style: none; margin: 0; padding: 0; }
|
||||
.mail-list li {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mail-list li.unseen { background: #f8fafc; }
|
||||
.mail-list li:hover { background: #f3f4f6; }
|
||||
.mail-list .subject { font-weight: 500; }
|
||||
.mail-list .from { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* token 弹框 */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(17, 24, 39, .45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: min(360px, 92vw);
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||
}
|
||||
.modal h3 { margin: 0 0 10px; color: var(--text); font-size: 14px; }
|
||||
.modal .actions { margin-top: 12px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.modal.modal-wide {
|
||||
width: min(640px, 94vw);
|
||||
max-height: min(92vh, 880px);
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.modal.modal-wide h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 阅读邮件:宽弹窗,正文区域独立滚动 */
|
||||
.modal.modal-mail {
|
||||
width: min(1320px, 98vw);
|
||||
max-height: 96vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-mail-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-mail-toolbar h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.modal-mail-meta {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-mail-meta p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.modal-mail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
.modal-mail-body iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: min(720px, 72vh);
|
||||
border: 0;
|
||||
}
|
||||
.modal-mail-body pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block; font-size: 12px; color: var(--muted);
|
||||
padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px;
|
||||
}
|
||||
|
||||
pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; font-family: var(--mono); }
|
||||
|
||||
.access-gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--radius: 6px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
button,
|
||||
.btn {
|
||||
font: inherit;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
button:hover,
|
||||
.btn:hover { border-color: #c5c9d0; background: #f3f4f6; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
.btn-danger { color: var(--danger); border-color: var(--border); background: var(--surface); }
|
||||
.btn-danger:hover { background: #fef2f2; border-color: #fca5a5; }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .15);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 54px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 0 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-label-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-label-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sidebar-mini-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-mini-btn:hover {
|
||||
background: #eff6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin: 0 0 2px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sidebar-group-header:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.sidebar-group-chevron {
|
||||
flex-shrink: 0;
|
||||
width: 1em;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sidebar-group-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: #f3f4f6;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sidebar-group-body {
|
||||
padding: 0 0 4px 4px;
|
||||
}
|
||||
.sidebar-group-body .sidebar-item {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: #f3f4f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: #eff6ff;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-item-title { font-weight: 500; }
|
||||
.sidebar-item-sub { font-size: 12px; word-break: break-all; }
|
||||
|
||||
.sidebar-muted {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sidebar-foot:hover { color: var(--accent); }
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 主内容区占满侧栏右侧可用宽度(不再限制 900px) */
|
||||
.main-inner {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 20px 28px 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: none;
|
||||
}
|
||||
.sidebar-right {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
order: 3;
|
||||
}
|
||||
.main-area { order: 2; }
|
||||
.sidebar:not(.sidebar-right) { order: 1; }
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.sidebar-label-row { width: 100%; flex-wrap: wrap; }
|
||||
.sidebar-label { width: auto; }
|
||||
.sidebar-label-actions { width: 100%; justify-content: flex-start; }
|
||||
.sidebar-group { width: 100%; }
|
||||
.sidebar-item { flex: 1 1 auto; min-width: 44%; margin-bottom: 0; }
|
||||
.sidebar-foot { width: 100%; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 54px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topbar .brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
cursor: pointer; user-select: none; font-weight: 600;
|
||||
}
|
||||
.topbar .logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar nav { margin-left: auto; display: flex; gap: 14px; }
|
||||
.topbar nav a { color: var(--muted); }
|
||||
.topbar nav a.active { color: var(--text); font-weight: 600; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0 0 12px; font-weight: 600; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; color: var(--muted); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; background: #fafbfc; }
|
||||
tr:hover td { background: #f9fafb; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; align-items: center; margin-bottom: 10px; }
|
||||
@media (max-width: 640px) {
|
||||
.form-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.topbar nav { gap: 10px; }
|
||||
.container { padding: 16px 12px 48px; }
|
||||
th:nth-child(3), td:nth-child(3),
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 260px 1fr; gap: 16px; }
|
||||
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.mail-list { list-style: none; margin: 0; padding: 0; }
|
||||
.mail-list li {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mail-list li.unseen { background: #f8fafc; }
|
||||
.mail-list li:hover { background: #f3f4f6; }
|
||||
.mail-list .subject { font-weight: 500; }
|
||||
.mail-list .from { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* token 弹框 */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(17, 24, 39, .45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: min(360px, 92vw);
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||
}
|
||||
.modal h3 { margin: 0 0 10px; color: var(--text); font-size: 14px; }
|
||||
.modal .actions { margin-top: 12px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.modal.modal-wide {
|
||||
width: min(640px, 94vw);
|
||||
max-height: min(92vh, 880px);
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.modal.modal-wide h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 阅读邮件:宽弹窗,正文区域独立滚动 */
|
||||
.modal.modal-mail {
|
||||
width: min(1320px, 98vw);
|
||||
max-height: 96vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-mail-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-mail-toolbar h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.modal-mail-meta {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-mail-meta p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.modal-mail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
.modal-mail-body iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: min(720px, 72vh);
|
||||
border: 0;
|
||||
}
|
||||
.modal-mail-body pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block; font-size: 12px; color: var(--muted);
|
||||
padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px;
|
||||
}
|
||||
|
||||
pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow: auto; font-family: var(--mono); }
|
||||
|
||||
.access-gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
})
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// 生产:`.env.production` 中 VITE_API_URL=https://post.api.smyhub.com
|
||||
// 开发:未设置时走相对路径 + server.proxy
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user