完成 UI-01 应用外壳与设计令牌

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent d4f6b755e5
commit 4f0b4884b6
8 changed files with 574 additions and 28 deletions
+39 -25
View File
@@ -2,6 +2,8 @@
import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { SettingsPanel } from './components/SettingsPanel'
import { AppShell } from './components/layout/AppShell'
import { AppPage } from './components/layout/navigation'
import { Contact, Message } from '../../shared/types'
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
@@ -149,6 +151,7 @@ function App(): React.ReactElement {
const [showDbKey, setShowDbKey] = useState(false)
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
@@ -830,30 +833,41 @@ function App(): React.ReactElement {
}
return (
<div className="app-container">
<Sidebar
contacts={filteredContacts}
selectedContact={selectedContact}
onSelectContact={handleSelectContact}
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
key={`${selectedContact?.md5}-${contentFilter}`}
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
/>
<AppShell
activePage={activePage}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onPageChange={setActivePage}
onOpenSettings={() => {
setActivePage('settings')
setShowSettings(true)
}}
>
<div className="app-container">
<Sidebar
contacts={filteredContacts}
selectedContact={selectedContact}
onSelectContact={handleSelectContact}
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isAuthenticated}
onOpenSettings={() => setShowSettings(true)}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
key={`${selectedContact?.md5}-${contentFilter}`}
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
/>
</div>
<SettingsPanel
open={showSettings}
selfInfo={selfInfo}
@@ -866,7 +880,7 @@ function App(): React.ReactElement {
void loadContacts()
}}
/>
</div>
</AppShell>
)
}
+275 -3
View File
@@ -13,9 +13,9 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--wxex-font);
background-color: var(--wxex-bg-app);
color: var(--wxex-text-primary);
height: 100vh;
overflow: hidden;
}
@@ -25,10 +25,277 @@ body {
display: flex;
}
.app-shell {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--wxex-bg-app);
}
.app-primary-rail {
box-sizing: border-box;
width: var(--wxex-nav-width);
flex: 0 0 var(--wxex-nav-width);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
padding: 12px 8px;
overflow: hidden;
-webkit-app-region: drag;
}
.app-brand {
box-sizing: border-box;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand);
color: #ffffff;
-webkit-app-region: no-drag;
}
.app-brand svg {
width: 34px;
height: 34px;
}
.app-brand path {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.app-brand circle {
fill: currentColor;
}
.primary-navigation {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
margin-top: 18px;
-webkit-app-region: no-drag;
}
.primary-nav-item {
width: 100%;
height: 58px;
flex: 0 0 58px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font-family: var(--wxex-font);
}
.primary-nav-item:hover {
background: rgba(255, 255, 255, 0.46);
color: var(--wxex-text-primary);
}
.primary-nav-item.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.primary-nav-mark {
width: 24px;
height: 24px;
display: grid;
place-items: center;
}
.primary-nav-mark svg {
width: 22px;
height: 22px;
}
.primary-nav-mark path,
.primary-nav-mark circle {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.primary-nav-label {
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
}
.app-rail-account {
box-sizing: border-box;
width: 100%;
flex: 0 0 auto;
-webkit-app-region: no-drag;
}
.account-summary {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font-family: var(--wxex-font);
text-align: left;
}
.account-summary:hover {
background: rgba(255, 255, 255, 0.72);
}
.account-summary.compact {
justify-content: center;
padding: 8px 0;
}
.account-summary-avatar {
box-sizing: border-box;
position: relative;
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font-size: 14px;
font-weight: 700;
overflow: hidden;
}
.account-summary-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.account-summary-status {
position: absolute;
right: 2px;
bottom: 2px;
width: 9px;
height: 9px;
border: 2px solid var(--wxex-bg-elevated);
border-radius: 999px;
background: var(--wxex-text-muted);
}
.account-summary-status.ready {
background: var(--wxex-success);
}
.account-summary-text {
min-width: 0;
display: flex;
flex-direction: column;
}
.account-summary-name {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-summary-meta {
overflow: hidden;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell-main {
box-sizing: border-box;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
align-items: stretch;
overflow: hidden;
padding-top: var(--wxex-shell-content-top);
background: var(--wxex-bg-main);
}
.app-page-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--wxex-text-secondary);
text-align: center;
}
.app-page-placeholder-eyebrow {
margin-bottom: 8px;
color: var(--wxex-brand);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.app-page-placeholder h2 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.app-page-placeholder p {
margin: 0;
font-size: 13px;
line-height: 20px;
}
.app-container {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
align-items: stretch;
overflow: hidden;
}
.sidebar {
@@ -37,11 +304,13 @@ body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
flex-shrink: 0;
/* Prevent shrinking */
}
.sidebar-header {
flex: 0 0 auto;
padding: 10px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
@@ -208,12 +477,15 @@ body {
display: flex;
flex-direction: column;
background-color: var(--chat-bg);
height: 100%;
min-height: 0;
min-width: 0;
/* Allow flex item to shrink below content size */
}
.chat-header {
height: 50px;
flex: 0 0 50px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
@@ -0,0 +1,50 @@
import React from 'react'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AccountSummaryProps {
selfInfo: SelfInfo | null
dbReady: boolean
compact?: boolean
onClick?: () => void
}
export function AccountSummary({
selfInfo,
dbReady,
compact = false,
onClick
}: AccountSummaryProps): React.ReactElement {
const displayName = dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
const initial = (displayName || '?').charAt(0)
return (
<button
type="button"
className={`account-summary ${compact ? 'compact' : ''}`}
onClick={onClick}
title={compact ? `${displayName}\n${subtitle}` : undefined}
>
<span className="account-summary-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" />
) : (
initial
)}
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
</span>
{!compact && (
<span className="account-summary-text">
<span className="account-summary-name">{displayName}</span>
<span className="account-summary-meta">{subtitle}</span>
</span>
)}
</button>
)
}
@@ -0,0 +1,73 @@
import React from 'react'
import { AccountSummary } from '../account/AccountSummary'
import { PrimaryNavigation } from './PrimaryNavigation'
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AppShellProps {
activePage: AppPage
selfInfo: SelfInfo | null
dbReady: boolean
onPageChange: (page: AppPage) => void
onOpenSettings: () => void
children: React.ReactNode
}
function BrandLogo(): React.ReactElement {
return (
<div className="app-brand" title="WechatExplorer" aria-label="WechatExplorer">
<svg viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<path d="M13 15.5 20 10l7 5.5" />
<path d="M13 24.5 20 30l7-5.5" />
<path d="M13 15.5v9" />
<path d="M27 15.5v9" />
<circle cx="20" cy="10" r="2.6" />
<circle cx="13" cy="15.5" r="2.6" />
<circle cx="27" cy="15.5" r="2.6" />
<circle cx="13" cy="24.5" r="2.6" />
<circle cx="27" cy="24.5" r="2.6" />
<circle cx="20" cy="30" r="2.6" />
</svg>
</div>
)
}
export function AppShell({
activePage,
selfInfo,
dbReady,
onPageChange,
onOpenSettings,
children
}: AppShellProps): React.ReactElement {
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
return (
<div className="app-shell">
<aside className="app-primary-rail">
<BrandLogo />
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
<div className="app-rail-account">
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} />
</div>
</aside>
<main className="app-shell-main">
{activePage === 'archive' ? (
children
) : (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{activeItem?.label || '工作区'}</h2>
<p> UI </p>
</div>
)}
</main>
</div>
)
}
@@ -0,0 +1,95 @@
import React from 'react'
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
interface NavIconProps {
page: AppPage
}
interface PrimaryNavigationProps {
activePage: AppPage
onPageChange: (page: AppPage) => void
}
function NavIcon({ page }: NavIconProps): React.ReactElement {
switch (page) {
case 'archive':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 5.5h14v14H5z" />
<path d="M8 8.5h8" />
<path d="M8 12h8" />
<path d="M8 15.5h5" />
</svg>
)
case 'search':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="10.5" cy="10.5" r="5.5" />
<path d="m15 15 4 4" />
</svg>
)
case 'report':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 4.5h7l3 3v12H7z" />
<path d="M14 4.5v3h3" />
<path d="M9.5 13.5h5" />
<path d="M9.5 16.5h4" />
</svg>
)
case 'export':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 4.5v9" />
<path d="m8.5 10 3.5 3.5 3.5-3.5" />
<path d="M5.5 15.5v3h13v-3" />
</svg>
)
case 'api':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M8 8.5 4.5 12 8 15.5" />
<path d="m16 8.5 3.5 3.5-3.5 3.5" />
<path d="m13.5 6.5-3 11" />
</svg>
)
case 'settings':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="3" />
<path d="M12 4.5v2" />
<path d="M12 17.5v2" />
<path d="M4.5 12h2" />
<path d="M17.5 12h2" />
<path d="m6.8 6.8 1.4 1.4" />
<path d="m15.8 15.8 1.4 1.4" />
<path d="m17.2 6.8-1.4 1.4" />
<path d="m8.2 15.8-1.4 1.4" />
</svg>
)
}
}
export function PrimaryNavigation({
activePage,
onPageChange
}: PrimaryNavigationProps): React.ReactElement {
return (
<nav className="primary-navigation" aria-label="一级导航">
{PRIMARY_NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={`primary-nav-item ${activePage === item.id ? 'active' : ''}`}
onClick={() => onPageChange(item.id)}
title={item.label}
>
<span className="primary-nav-mark" aria-hidden>
<NavIcon page={item.id} />
</span>
<span className="primary-nav-label">{item.label}</span>
</button>
))}
</nav>
)
}
@@ -0,0 +1,15 @@
export type AppPage = 'archive' | 'search' | 'report' | 'export' | 'api' | 'settings'
export interface NavigationItem {
id: AppPage
label: string
}
export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '检索' },
{ id: 'report', label: '日报' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
{ id: 'settings', label: '设置' }
]
+1
View File
@@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/tokens.css'
import './assets/main.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+26
View File
@@ -0,0 +1,26 @@
:root {
--wxex-bg-app: #f4f6f5;
--wxex-bg-main: #fafbfa;
--wxex-bg-sidebar: #eef2f0;
--wxex-bg-elevated: #ffffff;
--wxex-text-primary: #202724;
--wxex-text-secondary: #66706b;
--wxex-text-muted: #929a96;
--wxex-border: #dde3e0;
--wxex-brand: #247a63;
--wxex-brand-hover: #1d6754;
--wxex-brand-soft: #ddede7;
--wxex-ai: #686edc;
--wxex-ai-soft: #eeeffd;
--wxex-success: #2e8b68;
--wxex-warning: #c68635;
--wxex-danger: #c85a5a;
--wxex-font:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--wxex-nav-width: 76px;
--wxex-shell-content-top: 12px;
--wxex-radius-sm: 4px;
--wxex-radius-md: 8px;
--wxex-radius-lg: 12px;
--wxex-shadow-popover: 0 4px 12px rgba(0, 0, 0, 0.08);
}