refactor: 代码拆分

This commit is contained in:
Wxw-Gu
2026-07-29 10:50:27 +08:00
parent 8e40487e08
commit 0adb064681
31 changed files with 10628 additions and 10039 deletions
+1
View File
@@ -76,6 +76,7 @@
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sass": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
+172 -4
View File
@@ -33,6 +33,7 @@ specifiers:
prettier: ^3.7.4
react: ^19.2.1
react-dom: ^19.2.1
sass: ^1.102.0
silk-wasm: ^3.7.1
typescript: ^5.9.3
vite: ^7.2.6
@@ -71,8 +72,9 @@ devDependencies:
prettier: 3.7.4
react: 19.2.1
react-dom: 19.2.1_react@19.2.1
sass: 1.102.0
typescript: 5.9.3
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
packages:
@@ -930,6 +932,132 @@ packages:
rimraf: 3.0.2
dev: true
/@parcel/watcher-android-arm64/2.6.0:
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
dev: true
optional: true
/@parcel/watcher-darwin-arm64/2.6.0:
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
dev: true
optional: true
/@parcel/watcher-darwin-x64/2.6.0:
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
dev: true
optional: true
/@parcel/watcher-freebsd-x64/2.6.0:
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
dev: true
optional: true
/@parcel/watcher-linux-arm-glibc/2.6.0:
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-arm-musl/2.6.0:
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-linux-arm64-glibc/2.6.0:
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-arm64-musl/2.6.0:
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-linux-x64-glibc/2.6.0:
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
dev: true
optional: true
/@parcel/watcher-linux-x64-musl/2.6.0:
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
dev: true
optional: true
/@parcel/watcher-win32-arm64/2.6.0:
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
dev: true
optional: true
/@parcel/watcher-win32-x64/2.6.0:
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
dev: true
optional: true
/@parcel/watcher/2.6.0:
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'}
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.6.0
'@parcel/watcher-darwin-arm64': 2.6.0
'@parcel/watcher-darwin-x64': 2.6.0
'@parcel/watcher-freebsd-x64': 2.6.0
'@parcel/watcher-linux-arm-glibc': 2.6.0
'@parcel/watcher-linux-arm-musl': 2.6.0
'@parcel/watcher-linux-arm64-glibc': 2.6.0
'@parcel/watcher-linux-arm64-musl': 2.6.0
'@parcel/watcher-linux-x64-glibc': 2.6.0
'@parcel/watcher-linux-x64-musl': 2.6.0
'@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0
dev: true
optional: true
/@pkgjs/parseargs/0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1429,7 +1557,7 @@ packages:
'@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
transitivePeerDependencies:
- supports-color
dev: true
@@ -1882,6 +2010,13 @@ packages:
supports-color: 7.2.0
dev: true
/chokidar/5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
dependencies:
readdirp: 5.0.0
dev: true
/chownr/2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@@ -2236,7 +2371,7 @@ packages:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 7.2.7_@types+node@22.19.2
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
transitivePeerDependencies:
- supports-color
dev: true
@@ -3091,6 +3226,10 @@ packages:
engines: {node: '>= 4'}
dev: true
/immutable/5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
dev: true
/import-fresh/3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3742,6 +3881,11 @@ packages:
dev: true
optional: true
/node-addon-api/7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
dev: true
optional: true
/node-api-version/0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
dependencies:
@@ -3962,6 +4106,12 @@ packages:
engines: {node: '>=12'}
dev: true
/picomatch/4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
dev: true
optional: true
/plist/3.1.0:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
@@ -4093,6 +4243,11 @@ packages:
util-deprecate: 1.0.2
dev: true
/readdirp/5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
dev: true
/reflect.getprototypeof/1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -4250,6 +4405,18 @@ packages:
truncate-utf8-bytes: 1.0.2
dev: true
/sass/1.102.0:
resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==}
engines: {node: '>=20.19.0'}
hasBin: true
dependencies:
chokidar: 5.0.0
immutable: 5.1.9
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.6.0
dev: true
/sax/1.4.3:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
dev: true
@@ -4784,7 +4951,7 @@ packages:
dev: true
optional: true
/vite/7.2.7_@types+node@22.19.2:
/vite/7.2.7_kx7yyowhop237vgn7dp2p54shq:
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4830,6 +4997,7 @@ packages:
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.53.3
sass: 1.102.0
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,107 @@
import React from 'react'
import type { Contact, SelfInfo } from './exportTypes'
import { displayName } from './exportUtils'
interface ExportContactPanelProps {
contacts: Contact[]
filteredContacts: Contact[]
activeContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
contactFilter: string
contactType: 'all' | 'group' | 'user'
onContactFilterChange: (value: string) => void
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
export function ExportContactPanel({
contacts,
filteredContacts,
activeContact,
selfInfo,
dbReady,
contactFilter,
contactType,
onContactFilterChange,
onContactTypeChange,
onSelectContact,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
return (
<aside className="export-contact-panel">
<div className="export-panel-header">
<div className="export-panel-title-row">
<h2></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span>
</div>
<label className="export-search-field">
<span aria-hidden></span>
<input
value={contactFilter}
onChange={(event) => onContactFilterChange(event.target.value)}
placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天"
/>
</label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactType === value ? 'active' : ''}
onClick={() => onContactTypeChange(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
</span>
<span className="export-contact-copy">
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
)
})}
</div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
<span className="export-account-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" />
) : (
(selfInfo?.nickname || '我').slice(0, 1)
)}
</span>
<span>
<strong>{selfInfo?.nickname || '当前账号'}</strong>
<small className={dbReady ? 'ready' : ''}>
{dbReady ? '数据库已连接' : '数据库未连接'}
</small>
</span>
</button>
</aside>
)
}
@@ -0,0 +1,164 @@
import React from 'react'
import type { Message } from './exportTypes'
import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes'
import { formatPreviewTime } from './exportUtils'
interface ExportPreviewPanelProps {
status: ExportStatus
previewItems: Message[]
previewMediaCount: number
previewBytes: number
selfInfo: SelfInfo | null
progress: ExportJobProgress | null
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
}
export function ExportPreviewPanel({
status,
previewItems,
previewMediaCount,
previewBytes,
selfInfo,
progress,
jobId,
onCancel,
onReveal
}: ExportPreviewPanelProps): React.ReactElement {
return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(previewItems.length
? previewItems
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={message.id}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`}
>
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.img || selfInfo?.avatar} alt="" />
) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1)
)}
</span>
<span className="export-preview-bubble">
<small>
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
{message.content || `[${message.type}]`}
</span>
</div>
))}
</div>
<div className="export-preview-stats export-preview-real-stats">
<span>
<strong>{previewItems.length}</strong>
</span>
<span>
<strong>{previewMediaCount}</strong>
</span>
<span>
<strong>
{previewBytes < 1024
? `${previewBytes} B`
: `${(previewBytes / 1024).toFixed(1)} KB`}
</strong>
</span>
</div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</>
)}
{status === 'running' && (
<div className="export-job-state">
<h2></h2>
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
</button>
</div>
)}
{status === 'completed' && (
<div className="export-job-state completed">
<div className="export-success-icon"></div>
<h2></h2>
<p></p>
<div className="export-complete-summary">
<span>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<button
type="button"
className="export-primary-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
</button>
<button
type="button"
className="export-open-folder-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
</button>
</div>
)}
</aside>
)
}
@@ -0,0 +1,57 @@
import React from 'react'
import type { ExportTaskRecord } from './exportTypes'
interface ExportTaskCenterProps {
open: boolean
taskCount: number
tasks: ExportTaskRecord[]
onToggle: () => void
onCancel: (jobId: string) => void
}
export function ExportTaskCenter({
open,
taskCount,
tasks,
onToggle,
onCancel
}: ExportTaskCenterProps): React.ReactElement {
return (
<>
<button type="button" className="export-task-center-button" onClick={onToggle}>
{taskCount > 0 ? ` (${taskCount})` : ''}
</button>
{open && (
<section className="export-task-center">
<div className="export-section-heading">
<h3></h3>
<span>{tasks.length} </span>
</div>
{tasks.length === 0 ? (
<p></p>
) : (
tasks.map((task) => (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.contactName}</strong>
<small>
{task.format.toUpperCase()} · {task.progress.phase}
</small>
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
</div>
))
)}
</section>
)}
</>
)
}
@@ -1,75 +1,21 @@
import React, { useMemo, useState } from 'react'
import type { Contact, Message } from '../../../../shared/types'
import type { Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
} from '../../../../shared/export'
import type { ExportRequest, ExportResult, ExportTaskRecord } from '../../../../shared/export'
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
type ExportStatus = 'idle' | 'running' | 'completed'
interface GroupMemberName {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void>
}
const messageKinds = [
['text', '文字'],
['image', '图片'],
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
] as const
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
html: { label: 'HTML', hint: '推荐' },
csv: { label: 'CSV' },
json: { label: 'JSON' },
markdown: { label: 'Markdown' }
}
const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
}
function formatPreviewTime(message: Message): string {
if (!message.createTime) return message.datetime || ''
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
import { ExportContactPanel } from './ExportContactPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter'
import type {
ExportFormat,
ExportRange,
ExportStatus,
ExportWorkspaceProps,
GroupMemberName
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
export function ExportWorkspace({
contacts,
@@ -313,97 +259,29 @@ export function ExportWorkspace({
return (
<div className="export-workspace">
<aside className="export-contact-panel">
<div className="export-panel-header">
<div className="export-panel-title-row">
<h2></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span>
</div>
<label className="export-search-field">
<span aria-hidden></span>
<input
value={contactFilter}
onChange={(event) => setContactFilter(event.target.value)}
placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天"
<ExportContactPanel
contacts={contacts}
filteredContacts={filteredContacts}
activeContact={activeContact}
selfInfo={selfInfo}
dbReady={dbReady}
contactFilter={contactFilter}
contactType={contactType}
onContactFilterChange={setContactFilter}
onContactTypeChange={setContactType}
onSelectContact={onSelectContact}
onOpenSettings={onOpenSettings}
/>
</label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactType === value ? 'active' : ''}
onClick={() => setContactType(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
</span>
<span className="export-contact-copy">
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
)
})}
</div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
<span className="export-account-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" />
) : (
(selfInfo?.nickname || '我').slice(0, 1)
)}
</span>
<span>
<strong>{selfInfo?.nickname || '当前账号'}</strong>
<small className={dbReady ? 'ready' : ''}>
{dbReady ? '数据库已连接' : '数据库未连接'}
</small>
</span>
</button>
</aside>
<main className="export-config-panel">
<div className="export-config-scroll">
<button type="button" className="export-task-center-button" onClick={() => setTaskCenterOpen((open) => !open)}>
{taskCount > 0 ? ` (${taskCount})` : ''}
</button>
{taskCenterOpen && (
<section className="export-task-center">
<div className="export-section-heading"><h3></h3><span>{exportTasks.length} </span></div>
{exportTasks.length === 0 ? <p></p> : exportTasks.map((task) => (
<div className="export-task-row" key={task.jobId}>
<span><strong>{task.contactName}</strong><small>{task.format.toUpperCase()} · {task.progress.phase}</small></span>
<span className="export-task-progress"><i style={{ width: `${task.progress.percent ?? 0}%` }} /><b>{task.progress.percent ?? 0}%</b></span>
{task.status === 'running' && <button type="button" onClick={() => void onCancelExport(task.jobId)}></button>}
</div>
))}
</section>
)}
<ExportTaskCenter
open={taskCenterOpen}
taskCount={taskCount}
tasks={exportTasks}
onToggle={() => setTaskCenterOpen((open) => !open)}
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/>
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
@@ -678,145 +556,20 @@ export function ExportWorkspace({
</footer>
</main>
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(previewItems.length
? previewItems
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={message.id}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`}
>
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.img || selfInfo?.avatar} alt="" />
) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1)
)}
</span>
<span className="export-preview-bubble">
<small>
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
{message.content || `[${message.type}]`}
</span>
</div>
))}
</div>
<div className="export-preview-stats export-preview-real-stats">
<span>
<strong>{previewItems.length}</strong>
</span>
<span>
<strong>{previewMediaCount}</strong>
</span>
<span>
<strong>{previewBytes < 1024 ? `${previewBytes} B` : `${(previewBytes / 1024).toFixed(1)} KB`}</strong>
</span>
</div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</>
)}
{status === 'running' && (
<div className="export-job-state">
<h2></h2>
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button
type="button"
className="export-cancel-button"
onClick={() => {
void window.api.cancelExport(jobId)
<ExportPreviewPanel
status={status}
previewItems={previewItems}
previewMediaCount={previewMediaCount}
previewBytes={previewBytes}
selfInfo={selfInfo}
progress={progress}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
setStatus('idle')
}}
>
</button>
</div>
)}
{status === 'completed' && (
<div className="export-job-state completed">
<div className="export-success-icon"></div>
<h2></h2>
<p></p>
<div className="export-complete-summary">
<span>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<button
type="button"
className="export-primary-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
<button
type="button"
className="export-open-folder-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
</div>
)}
</aside>
onReveal={(path) => void window.api.revealExport(path)}
/>
</div>
)
}
@@ -0,0 +1,44 @@
import type { Contact, Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode,
ExportRequest,
ExportResult,
ExportTaskRecord
} from '../../../../shared/export'
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
export type ExportStatus = 'idle' | 'running' | 'completed'
export interface GroupMemberName {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
export interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void>
}
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
@@ -0,0 +1,77 @@
import type { Contact, Message } from '../../../../shared/types'
import type { ExportFormat, GroupMemberName } from './exportTypes'
export const messageKinds = [
['text', '文字'],
['image', '图片'],
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
] as const
export const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
html: { label: 'HTML', hint: '推荐' },
csv: { label: 'CSV' },
json: { label: 'JSON' },
markdown: { label: 'Markdown' }
}
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
export function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
}
export function formatPreviewTime(message: Message): string {
if (!message.createTime) return message.datetime || ''
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
export function buildNameMap(
activeContact: Contact | null,
groupMembers: GroupMemberName[],
nameMode: string,
selfInfo: { wxid: string; nickname: string } | null
): Record<string, string> {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}
export function buildAvatarUrls(
activeContact: Contact | null,
groupMembers: GroupMemberName[],
selfInfo: { wxid: string; avatar?: string } | null
): Record<string, string> {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}
@@ -1,479 +1,47 @@
import React, { useMemo, useRef, useState } from 'react'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
import type { Contact, Message } from '../../../../shared/types'
import { SearchIcon } from '../chat/icons'
type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
type SearchScope = 'global' | 'conversation'
type SearchRange = 'today' | '7d' | '30d' | 'all'
type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
interface EvidenceItem {
contact: Contact
message: Message
}
interface AISearchCacheRecord {
version: 1
key: string
createdAt: number
answer: string
evidence: EvidenceItem[]
senderNames: Record<string, string>
messageCount: number
}
interface GroupMemberName {
wxid: string
nickname?: string
groupNickname?: string
remark?: string
wechatNickname?: string
}
interface SenderDirectory {
displayNames: Record<string, string>
aliases: Record<string, string[]>
}
interface SearchQueryPlan {
intent: SearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
}
interface SearchPassSummary {
label: string
keywords: string[]
messageCount: number
}
interface AISearchWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
dbReady: boolean
aiModelConfig: AIRuntimeModelConfig
onSelectContact: (contact: Contact) => void
onOpenEvidence: (contact: Contact, createTime?: number) => void
onOpenAISettings: () => void
onNotice: (message: string) => void
}
const RANGE_LABELS: Record<SearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
const SEARCH_CACHE_LIMIT = 20
const currentTimestamp = (): number => Date.now()
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
const messageText = (message: Message): string =>
String(message.content || '').trim() || `[${message.type || '消息'}]`
const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '')
const includesSearchAlias = (query: string, alias: string): boolean => {
const normalizedAlias = normalizeSearchText(alias.trim())
return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias)
}
const extractSearchKeywords = (query: string): string[] => {
const cleanedQuery = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
const tokens = cleanedQuery
.split(/[\s,,。!?!?、:;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
return Array.from(new Set(tokens))
}
const getFuzzySearchKeywords = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
const normalizeSearchTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
const buildLocalSearchPlan = (query: string): SearchQueryPlan => {
const keywords = extractSearchKeywords(query)
const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query)
const intent: SearchIntent = asksParticipants
? keywords.length
? 'mixed'
: 'participants'
: keywords.length
? 'topic'
: 'general'
return {
intent,
keywords,
variants: getFuzzySearchKeywords(keywords),
source: 'local'
}
}
const parseSearchPlanResponse = (value: string): Partial<SearchQueryPlan> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = ['general', 'topic', 'participants', 'mixed'].includes(String(parsed.intent))
? (parsed.intent as SearchIntent)
: undefined
return {
intent,
keywords: normalizeSearchTerms(parsed.keywords),
variants: normalizeSearchTerms(parsed.variants)
}
} catch {
return null
}
}
const mergeSearchPlans = (
localPlan: SearchQueryPlan,
aiPlan: Partial<SearchQueryPlan> | null
): SearchQueryPlan => {
if (!aiPlan) return localPlan
const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])])
const variants = normalizeSearchTerms([
...getFuzzySearchKeywords(keywords),
...(localPlan.variants || []),
...(aiPlan.variants || [])
])
return {
intent: aiPlan.intent || localPlan.intent,
keywords,
variants,
source: 'hybrid'
}
}
const messageIdentity = (message: Message): string =>
message.localId
? `local:${message.localId}`
: message.id || `${message.createTime}:${message.content}`
const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
`${contact.md5}:${messageIdentity(message)}`
const formatMessageTime = (message: Message): string => {
if (message.datetime) return message.datetime
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
return '未知时间'
}
const getRangeStart = (range: SearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
const days = range === '7d' ? 7 : 30
return Math.floor(Date.now() / 1000) - days * 86400
}
const messageDateKey = (message: Message): string => {
if (!message.createTime) return 'unknown'
const date = new Date(message.createTime * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatMessageDate = (dateKey: string): string => {
if (dateKey === 'unknown') return '--/--'
const [, month, day] = dateKey.split('-')
return `${month}/${day}`
}
const selectEvenly = <T,>(items: T[], count: number): T[] => {
if (count >= items.length) return items
if (count <= 0) return []
if (count === 1) return [items[items.length - 1]]
return Array.from({ length: count }, (_item, index) => {
const itemIndex = Math.round((index * (items.length - 1)) / (count - 1))
return items[itemIndex]
})
}
const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => {
const buckets = new Map<string, EvidenceItem[]>()
items.forEach((item) => {
const key = messageDateKey(item.message)
const bucket = buckets.get(key) || []
bucket.push(item)
buckets.set(key, bucket)
})
const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right))
const targetCount = Math.min(maxItems, items.length)
const counts = entries.map(() => 0)
let remaining = targetCount
while (remaining > 0) {
let bestIndex = -1
let bestRemaining = 0
entries.forEach(([, bucket], index) => {
const available = bucket.length - counts[index]
if (available > bestRemaining) {
bestIndex = index
bestRemaining = available
}
})
if (bestIndex < 0) break
counts[bestIndex] += 1
remaining -= 1
}
return entries
.flatMap((entry, index) => selectEvenly(entry[1], counts[index]))
.sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
}
const looksLikeUserId = (value: string): boolean =>
value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value)
const formatMemberName = (member: GroupMemberName): string =>
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
const senderName = (message: Message, contact: Contact, names: Record<string, string>): string => {
const identifiers = [message.senderId, message.from, message.name].filter(
(value): value is string => Boolean(value?.trim())
)
const mappedName = identifiers.map((value) => names[value]).find(Boolean)
if (mappedName) return mappedName
if (message.name && !looksLikeUserId(message.name)) return message.name
if (message.isSender) return '我'
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
m_nsNickName: contact.m_nsNickName,
type: contact.type,
avatar: contact.avatar
},
message: {
id: message.id,
from: message.from,
type: message.type,
datetime: message.datetime,
content: message.content,
isSender: message.isSender,
name: message.name,
senderId: message.senderId,
localId: message.localId,
serverId: message.serverId,
createTime: message.createTime
}
})
const buildSearchCacheKey = (
scope: SearchScope,
contactMd5: string,
range: SearchRange,
query: string
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
const parseSearchCacheKey = (
key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
try {
const parts = JSON.parse(key) as unknown
if (
!Array.isArray(parts) ||
!['global', 'conversation'].includes(String(parts[0])) ||
!['today', '7d', '30d', 'all'].includes(String(parts[2])) ||
typeof parts[3] !== 'string'
) {
return null
}
return {
scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange,
query: parts[3]
}
} catch {
return null
}
}
const readSearchCache = (key: string): AISearchCacheRecord | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const record = records.find((item) => item.version === 1 && item.key === key)
return record || null
} catch {
return null
}
}
const readSearchCacheByQuery = (
query: string
): { record: AISearchCacheRecord; location: ReturnType<typeof parseSearchCacheKey> } | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const normalizedQuery = query.trim().toLowerCase()
for (const record of records) {
const location = parseSearchCacheKey(record.key)
if (record.version === 1 && location?.query === normalizedQuery) {
return { record, location }
}
}
return null
} catch {
return null
}
}
const writeSearchCache = (record: AISearchCacheRecord): void => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice(
0,
SEARCH_CACHE_LIMIT
)
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
} catch {
// A large message result must not break the search itself.
}
}
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
const key = `${keyPrefix}-${index}`
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={key}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={key}>{part.slice(1, -1)}</code>
}
if (part.startsWith('*') && part.endsWith('*')) {
return <em key={key}>{part.slice(1, -1)}</em>
}
return <React.Fragment key={key}>{part}</React.Fragment>
})
const renderMarkdown = (value: string): React.ReactNode =>
value.split(/\r?\n/).map((line, index) => {
const key = `markdown-${index}`
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
}
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
if (bullet) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden></span>
<span>{inlineMarkdown(bullet[1], key)}</span>
</div>
)
}
const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line)
if (numbered) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
<span>{inlineMarkdown(numbered[1], key)}</span>
</div>
)
}
return <p key={key}>{inlineMarkdown(line, key)}</p>
})
import type {
AISearchCacheRecord,
AISearchWorkspaceProps,
EvidenceItem,
GroupMemberName,
SearchPassSummary,
SearchRange,
SearchScope,
SearchStage,
SenderDirectory
} from './searchTypes'
import {
RANGE_LABELS,
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY,
buildLocalSearchPlan,
buildSearchCacheKey,
compactCacheItem,
currentTimestamp,
evidenceIdentity,
formatMemberName,
formatMessageDate,
formatMessageTime,
getRangeStart,
includesSearchAlias,
messageDateKey,
messageIdentity,
messageText,
mergeSearchPlans,
normalizeSearchText,
parseSearchCacheKey,
parseSearchPlanResponse,
readSearchCache,
readSearchCacheByQuery,
selectEvenly,
selectEvidenceByDate,
senderName,
writeSearchCache
} from './searchUtils'
import { renderMarkdown } from './searchMarkdown'
export function AISearchWorkspace({
contacts,
@@ -0,0 +1,46 @@
import React from 'react'
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
const key = `${keyPrefix}-${index}`
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={key}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={key}>{part.slice(1, -1)}</code>
}
if (part.startsWith('*') && part.endsWith('*')) {
return <em key={key}>{part.slice(1, -1)}</em>
}
return <React.Fragment key={key}>{part}</React.Fragment>
})
export const renderMarkdown = (value: string): React.ReactNode =>
value.split(/\r?\n/).map((line, index) => {
const key = `markdown-${index}`
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
if (heading) {
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
}
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
if (bullet) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden></span>
<span>{inlineMarkdown(bullet[1], key)}</span>
</div>
)
}
const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line)
if (numbered) {
return (
<div key={key} className="ai-search-markdown-list-item">
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
<span>{inlineMarkdown(numbered[1], key)}</span>
</div>
)
}
return <p key={key}>{inlineMarkdown(line, key)}</p>
})
@@ -0,0 +1,59 @@
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
import type { Contact, Message } from '../../../../shared/types'
export type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
export type SearchScope = 'global' | 'conversation'
export type SearchRange = 'today' | '7d' | '30d' | 'all'
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
export interface EvidenceItem {
contact: Contact
message: Message
}
export interface AISearchCacheRecord {
version: 1
key: string
createdAt: number
answer: string
evidence: EvidenceItem[]
senderNames: Record<string, string>
messageCount: number
}
export interface GroupMemberName {
wxid: string
nickname?: string
groupNickname?: string
remark?: string
wechatNickname?: string
}
export interface SenderDirectory {
displayNames: Record<string, string>
aliases: Record<string, string[]>
}
export interface SearchQueryPlan {
intent: SearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
}
export interface SearchPassSummary {
label: string
keywords: string[]
messageCount: number
}
export interface AISearchWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
dbReady: boolean
aiModelConfig: AIRuntimeModelConfig
onSelectContact: (contact: Contact) => void
onOpenEvidence: (contact: Contact, createTime?: number) => void
onOpenAISettings: () => void
onNotice: (message: string) => void
}
@@ -0,0 +1,372 @@
import type { Contact, Message } from '../../../../shared/types'
import type { AISearchCacheRecord, EvidenceItem, GroupMemberName, SearchIntent, SearchQueryPlan, SearchRange, SearchScope } from './searchTypes'
export const RANGE_LABELS: Record<SearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
export const SEARCH_CACHE_LIMIT = 20
export const currentTimestamp = (): number => Date.now()
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const messageText = (message: Message): string =>
String(message.content || '').trim() || `[${message.type || '消息'}]`
export const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '')
export const includesSearchAlias = (query: string, alias: string): boolean => {
const normalizedAlias = normalizeSearchText(alias.trim())
return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias)
}
const extractSearchKeywords = (query: string): string[] => {
const cleanedQuery = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
const tokens = cleanedQuery
.split(/[\s,,。!?!?、:;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
return Array.from(new Set(tokens))
}
const getFuzzySearchKeywords = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
const normalizeSearchTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
export const buildLocalSearchPlan = (query: string): SearchQueryPlan => {
const keywords = extractSearchKeywords(query)
const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query)
const intent: SearchIntent = asksParticipants
? keywords.length
? 'mixed'
: 'participants'
: keywords.length
? 'topic'
: 'general'
return {
intent,
keywords,
variants: getFuzzySearchKeywords(keywords),
source: 'local'
}
}
export const parseSearchPlanResponse = (value: string): Partial<SearchQueryPlan> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = ['general', 'topic', 'participants', 'mixed'].includes(String(parsed.intent))
? (parsed.intent as SearchIntent)
: undefined
return {
intent,
keywords: normalizeSearchTerms(parsed.keywords),
variants: normalizeSearchTerms(parsed.variants)
}
} catch {
return null
}
}
export const mergeSearchPlans = (
localPlan: SearchQueryPlan,
aiPlan: Partial<SearchQueryPlan> | null
): SearchQueryPlan => {
if (!aiPlan) return localPlan
const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])])
const variants = normalizeSearchTerms([
...getFuzzySearchKeywords(keywords),
...(localPlan.variants || []),
...(aiPlan.variants || [])
])
return {
intent: aiPlan.intent || localPlan.intent,
keywords,
variants,
source: 'hybrid'
}
}
export const messageIdentity = (message: Message): string =>
message.localId
? `local:${message.localId}`
: message.id || `${message.createTime}:${message.content}`
export const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
`${contact.md5}:${messageIdentity(message)}`
export const formatMessageTime = (message: Message): string => {
if (message.datetime) return message.datetime
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
return '未知时间'
}
export const getRangeStart = (range: SearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
const days = range === '7d' ? 7 : 30
return Math.floor(Date.now() / 1000) - days * 86400
}
export const messageDateKey = (message: Message): string => {
if (!message.createTime) return 'unknown'
const date = new Date(message.createTime * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const formatMessageDate = (dateKey: string): string => {
if (dateKey === 'unknown') return '--/--'
const [, month, day] = dateKey.split('-')
return `${month}/${day}`
}
export const selectEvenly = <T,>(items: T[], count: number): T[] => {
if (count >= items.length) return items
if (count <= 0) return []
if (count === 1) return [items[items.length - 1]]
return Array.from({ length: count }, (_item, index) => {
const itemIndex = Math.round((index * (items.length - 1)) / (count - 1))
return items[itemIndex]
})
}
export const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => {
const buckets = new Map<string, EvidenceItem[]>()
items.forEach((item) => {
const key = messageDateKey(item.message)
const bucket = buckets.get(key) || []
bucket.push(item)
buckets.set(key, bucket)
})
const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right))
const targetCount = Math.min(maxItems, items.length)
const counts = entries.map(() => 0)
let remaining = targetCount
while (remaining > 0) {
let bestIndex = -1
let bestRemaining = 0
entries.forEach(([, bucket], index) => {
const available = bucket.length - counts[index]
if (available > bestRemaining) {
bestIndex = index
bestRemaining = available
}
})
if (bestIndex < 0) break
counts[bestIndex] += 1
remaining -= 1
}
return entries
.flatMap((entry, index) => selectEvenly(entry[1], counts[index]))
.sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
}
const looksLikeUserId = (value: string): boolean =>
value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value)
export const formatMemberName = (member: GroupMemberName): string =>
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
export const senderName = (message: Message, contact: Contact, names: Record<string, string>): string => {
const identifiers = [message.senderId, message.from, message.name].filter(
(value): value is string => Boolean(value?.trim())
)
const mappedName = identifiers.map((value) => names[value]).find(Boolean)
if (mappedName) return mappedName
if (message.name && !looksLikeUserId(message.name)) return message.name
if (message.isSender) return '我'
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
}
export const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
contact: {
md5: contact.md5,
m_nsUsrName: contact.m_nsUsrName,
m_nsNickName: contact.m_nsNickName,
type: contact.type,
avatar: contact.avatar
},
message: {
id: message.id,
from: message.from,
type: message.type,
datetime: message.datetime,
content: message.content,
isSender: message.isSender,
name: message.name,
senderId: message.senderId,
localId: message.localId,
serverId: message.serverId,
createTime: message.createTime
}
})
export const buildSearchCacheKey = (
scope: SearchScope,
contactMd5: string,
range: SearchRange,
query: string
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
export const parseSearchCacheKey = (
key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
try {
const parts = JSON.parse(key) as unknown
if (
!Array.isArray(parts) ||
!['global', 'conversation'].includes(String(parts[0])) ||
!['today', '7d', '30d', 'all'].includes(String(parts[2])) ||
typeof parts[3] !== 'string'
) {
return null
}
return {
scope: parts[0] as SearchScope,
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
range: parts[2] as SearchRange,
query: parts[3]
}
} catch {
return null
}
}
export const readSearchCache = (key: string): AISearchCacheRecord | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const record = records.find((item) => item.version === 1 && item.key === key)
return record || null
} catch {
return null
}
}
export const readSearchCacheByQuery = (
query: string
): { record: AISearchCacheRecord; location: ReturnType<typeof parseSearchCacheKey> } | null => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const normalizedQuery = query.trim().toLowerCase()
for (const record of records) {
const location = parseSearchCacheKey(record.key)
if (record.version === 1 && location?.query === normalizedQuery) {
return { record, location }
}
}
return null
} catch {
return null
}
}
export const writeSearchCache = (record: AISearchCacheRecord): void => {
try {
const records = JSON.parse(
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
) as AISearchCacheRecord[]
const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice(
0,
SEARCH_CACHE_LIMIT
)
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
} catch {
// A large message result must not break the search itself.
}
}
+1 -5
View File
@@ -1,11 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/tokens.css'
import './assets/main.css'
import './styles/search.css'
import './styles/archive.css'
import './styles/settings-advanced.css'
import './styles/index.scss'
window.addEventListener('error', (event) => {
void window.api
+769
View File
@@ -0,0 +1,769 @@
@media (max-width: 700px) {
.settings-account-overview {
grid-template-columns: minmax(0, 1fr);
}
.settings-account-actions {
grid-row: auto;
grid-column: 1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-account-root {
grid-column: 1;
}
.database-key-status-card dl,
.database-key-diagnostics dl {
grid-template-columns: 1fr;
}
.database-key-auto-heading {
flex-direction: column;
}
.database-key-phases {
grid-template-columns: 1fr;
}
.database-key-phases li {
padding: 0 0 0 28px;
text-align: left;
}
.database-key-phases li::before {
top: -2px;
left: 0;
transform: none;
}
.image-decrypt-status dl,
.image-key-grid {
grid-template-columns: 1fr;
}
.image-decrypt-status .image-decrypt-wide {
grid-column: 1;
}
.image-auto-heading {
flex-direction: column;
}
.image-auto-phases {
grid-template-columns: 1fr;
}
.image-auto-phases li {
padding: 0 0 0 28px;
text-align: left;
}
.image-auto-phases li::before {
top: -2px;
left: 0;
transform: none;
}
.image-auto-success {
grid-template-columns: 1fr;
}
.image-auto-success button {
grid-column: 1;
grid-row: auto;
justify-self: start;
}
.image-resource-checks > div {
align-items: flex-start;
}
.image-resource-checks small {
white-space: normal;
text-align: right;
}
.ai-provider-card dl,
.ai-provider-form-grid,
.ai-provider-advanced > div {
grid-template-columns: 1fr;
}
.ai-provider-form-grid .wide,
.ai-provider-advanced .wide {
grid-column: 1;
}
.ai-model-row {
grid-template-columns: 1fr;
}
}
.api-center-layout > * {
min-width: 0;
min-height: 0;
box-sizing: border-box;
}
.api-section-heading h2,
.api-introduction h2,
.api-integrations h2,
.api-runtime-title h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 17px/24px var(--wxex-font);
}
.ready-text {
color: var(--wxex-success);
}
.api-main {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--wxex-bg-main);
}
.api-main-scroll {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 22px 28px 36px;
}
.api-workspace-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--wxex-border);
}
.api-title-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.api-title-line h1 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 22px/30px var(--wxex-font);
}
.api-workspace-heading p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
font: 13px/19px var(--wxex-font);
}
.api-skill-status,
.api-version {
padding: 2px 7px;
border-radius: 5px;
font: 700 11px/17px var(--wxex-font);
}
.api-skill-status.ready {
color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
.api-skill-status.error {
color: var(--wxex-danger);
background: #fff1f1;
}
.api-version {
color: var(--wxex-text-muted);
background: var(--wxex-bg-app);
}
.api-header-actions,
.api-tester-actions,
.api-runtime-actions,
.api-skill-file-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.api-header-actions button,
.api-tester-actions button,
.api-runtime-actions button,
.api-endpoint-row button,
.api-response-summary + pre + p + button,
.api-skill-file-actions button {
min-height: 32px;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.api-header-actions button:hover,
.api-tester-actions button:hover,
.api-runtime-actions button:hover,
.api-endpoint-row button:hover,
.api-skill-file-actions button:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
.api-primary-button {
border-color: var(--wxex-brand) !important;
background: var(--wxex-brand) !important;
color: #fff !important;
}
.api-primary-button:hover:not(:disabled),
.api-primary-button:focus-visible,
.api-primary-button:active {
background: var(--wxex-brand-hover) !important;
color: #fff !important;
}
.api-primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.api-trust-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #f5f8f6;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-trust-bar i {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--wxex-text-muted);
}
.api-introduction {
margin-top: 24px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
.api-introduction p {
margin: 10px 0 16px;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.api-flow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px;
border: 1px dashed var(--wxex-border);
border-radius: var(--wxex-radius-md);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-flow span,
.api-flow strong {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: #fff;
white-space: nowrap;
}
.api-flow strong {
color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
.api-flow b {
color: var(--wxex-text-muted);
font-size: 17px;
}
.api-endpoint-catalog,
.api-request-tester {
margin-top: 26px;
}
.skill-install-flow {
margin-top: 26px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
.skill-flow-steps {
display: grid;
gap: 16px;
margin-top: 16px;
}
.skill-flow-steps > section {
position: relative;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 10px;
color: var(--wxex-text-muted);
}
.skill-flow-steps > section:not(:last-child)::after {
position: absolute;
top: 28px;
left: 12px;
bottom: -16px;
width: 1px;
background: var(--wxex-border);
content: '';
}
.skill-flow-steps > section > b {
position: relative;
z-index: 1;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
background: var(--wxex-bg-elevated);
font: 700 12px/1 var(--wxex-font);
}
.skill-flow-steps > section.active > b,
.skill-flow-steps > section.done > b {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
.skill-flow-steps h3 {
margin: 1px 0 5px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.skill-flow-steps p {
margin: 3px 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.skill-flow-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.skill-flow-actions button,
.api-skill-details button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-flow-actions button:disabled,
.api-skill-details button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.skill-target-selector {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.skill-target-selector button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-target-selector button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.api-skill-details {
margin-top: 16px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.api-skill-details summary {
padding: 12px 14px;
color: var(--wxex-text-primary);
cursor: pointer;
font: 700 13px/18px var(--wxex-font);
}
.api-skill-details > dl,
.api-skill-details > div {
margin: 0;
padding: 0 14px 14px;
}
.api-skill-details dl div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
padding: 4px 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-skill-details dd {
margin: 0;
color: var(--wxex-text-primary);
}
.api-skill-more {
position: relative;
}
.api-skill-more summary {
display: grid;
min-height: 32px;
place-items: center;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
list-style: none;
}
.api-skill-more[open] > button {
position: absolute;
z-index: 2;
top: 36px;
right: 0;
width: 170px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
box-shadow: var(--wxex-shadow-popover);
font: 12px/18px var(--wxex-font);
text-align: left;
}
.api-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.api-section-heading > span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.api-endpoint-table {
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.api-endpoint-head,
.api-endpoint-row {
display: grid;
grid-template-columns: 72px minmax(128px, 1.1fr) minmax(150px, 1.2fr) 90px;
align-items: center;
gap: 12px;
padding: 11px 14px;
}
.api-endpoint-head {
background: #f1f4f2;
color: var(--wxex-text-secondary);
font: 700 12px/18px var(--wxex-font);
}
.api-endpoint-row {
min-height: 54px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-endpoint-row.active {
background: #f7faf8;
}
.api-endpoint-row code {
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
}
.api-endpoint-row small {
display: block;
color: var(--wxex-text-muted);
}
.api-endpoint-row > span:last-child {
display: flex;
gap: 4px;
}
.api-endpoint-row button {
min-height: 26px;
padding: 0 7px;
font-size: 11px;
}
.api-method {
display: inline-block;
color: var(--wxex-brand);
font:
700 11px/18px ui-monospace,
monospace;
}
.api-method.post {
color: var(--wxex-ai);
}
.api-request-meta {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #f5f8f6;
overflow: hidden;
}
.api-request-meta code {
overflow: hidden;
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-param-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.api-param-grid label,
.api-json-input {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-param-grid label span b {
margin-left: 5px;
color: var(--wxex-danger);
font-size: 11px;
}
.api-param-grid input,
.api-json-input textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: none;
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.api-param-grid input {
height: 34px;
padding: 0 9px;
}
.api-json-input {
margin-top: 10px;
}
.api-json-input textarea {
min-height: 180px;
padding: 10px;
resize: vertical;
font-family: ui-monospace, monospace;
}
.api-tester-actions {
justify-content: flex-end;
margin-top: 12px;
}
.api-inline-error {
margin: 10px 0 0;
padding: 8px 10px;
border: 1px solid rgba(200, 90, 90, 0.35);
border-radius: var(--wxex-radius-sm);
background: #fff4f4;
color: var(--wxex-danger);
font: 12px/18px var(--wxex-font);
}
.api-runtime-panel {
border-left: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
overflow: hidden;
}
.api-runtime-scroll {
height: 100%;
overflow-y: auto;
}
.api-runtime-scroll section {
padding: 18px 16px;
border-bottom: 1px solid var(--wxex-border);
}
.api-runtime-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.api-runtime-title > span {
font: 700 12px/18px var(--wxex-font);
}
.api-runtime-title .ready {
color: var(--wxex-success);
}
.api-runtime-title .stopped {
color: var(--wxex-text-muted);
}
.api-runtime-scroll h3 {
margin: 0 0 12px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.api-runtime-scroll dl {
margin: 14px 0;
}
.api-runtime-scroll dl div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
font: 12px/18px var(--wxex-font);
}
.api-runtime-scroll dt {
color: var(--wxex-text-secondary);
}
.api-runtime-scroll dd {
margin: 0;
color: var(--wxex-text-primary);
text-align: right;
}
.warning-text {
color: var(--wxex-warning);
}
.api-runtime-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.api-runtime-actions button {
min-height: 30px;
padding: 0 6px;
}
.api-security-warning {
margin: 12px 16px;
padding: 9px 10px;
border-left: 3px solid var(--wxex-warning);
background: #fff8ec;
color: #915d1e;
font: 12px/18px var(--wxex-font);
}
.api-response-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
}
.api-runtime-scroll pre {
max-height: 180px;
overflow: auto;
margin: 10px 0 8px;
padding: 10px;
border-radius: var(--wxex-radius-sm);
background: #1f2824;
color: #cde7dc;
font:
11px/16px ui-monospace,
monospace;
white-space: pre-wrap;
word-break: break-word;
}
.api-response-meta,
.api-empty-text {
margin: 0 0 9px;
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
.api-history {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.api-history li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--wxex-border);
}
.api-history span {
overflow: hidden;
color: var(--wxex-text-primary);
font:
11px/17px ui-monospace,
monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-history b {
font: 700 11px/17px var(--wxex-font);
}
.api-history small {
grid-column: 1 / -1;
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
}
.api-privacy {
background: #f5f8f6;
}
.api-privacy p {
margin: 0;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.api-markdown-overlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: stretch;
justify-content: center;
padding: 20px;
background: rgba(32, 39, 36, 0.24);
}
.api-markdown-overlay > div {
display: flex;
width: min(820px, 100%);
flex-direction: column;
min-height: 0;
padding: 14px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
}
.api-markdown-overlay header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.api-markdown-overlay header > div {
display: flex;
align-items: center;
gap: 8px;
}
.api-markdown-overlay header span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.api-markdown-overlay button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
}
.api-markdown-overlay pre,
.skill-markdown-preview {
min-height: 0;
overflow: auto;
margin: 14px 0 0;
white-space: pre-wrap;
color: var(--wxex-text-primary);
font: 12px/19px var(--wxex-font);
}
.skill-markdown-preview h1 {
font-size: 20px;
}
.skill-markdown-preview h2 {
margin-top: 18px;
font-size: 16px;
}
.skill-markdown-preview p {
margin: 6px 0;
}
.skill-markdown-preview li {
margin-left: 18px;
}
@@ -1,11 +1,11 @@
.archive-jump-target-group {
z-index: 2;
}
.archive-jump-message {
z-index: 1;
animation: archive-jump-flash 0.72s ease-in-out 2;
}
}
@keyframes archive-jump-flash {
0%,
@@ -20,9 +20,11 @@
}
@media (prefers-reduced-motion: reduce) {
.archive-jump-target-group {
.archive-jump-message {
outline: 3px solid rgba(38, 128, 103, 0.58);
outline-offset: 5px;
animation: none;
}
}
}
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
.conversation-sidebar {
box-sizing: border-box;
display: flex;
height: 100%;
min-height: 0;
flex: 0 0 auto;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-primary);
}
.conversation-sidebar-header {
box-sizing: border-box;
flex: 0 0 auto;
padding: 16px 14px 12px;
border-bottom: 1px solid var(--wxex-border);
-webkit-app-region: drag;
}
.conversation-sidebar-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 24px;
}
span {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
}
.conversation-search {
box-sizing: border-box;
display: flex;
height: 34px;
align-items: center;
gap: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 0 10px;
color: var(--wxex-text-muted);
-webkit-app-region: no-drag;
&:focus-within {
border-color: rgba(36, 122, 99, 0.58);
color: var(--wxex-brand);
}
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
&::placeholder {
color: var(--wxex-text-muted);
}
}
}
.conversation-search-icon {
width: 17px;
height: 17px;
display: grid;
place-items: center;
flex: 0 0 auto;
}
.conversation-search-icon svg {
width: 17px;
height: 17px;
}
.conversation-search-icon circle,
.conversation-search-icon path {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.conversation-date-range {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
margin-top: 10px;
-webkit-app-region: no-drag;
}
.conversation-date-range-button {
box-sizing: border-box;
height: 30px;
min-width: 0;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font: 12px/16px var(--wxex-font);
white-space: nowrap;
&:hover {
border-color: rgba(36, 122, 99, 0.28);
color: var(--wxex-text-primary);
}
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #ffffff;
font-weight: 600;
}
}
.conversation-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 8px 12px;
}
.conversation-virtual-content {
position: relative;
width: 100%;
}
.conversation-virtual-row {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.conversation-section + .conversation-section {
margin-top: 6px;
}
.conversation-section-header {
box-sizing: border-box;
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/16px var(--wxex-font);
padding: 0 8px;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.48);
color: var(--wxex-text-primary);
}
}
.conversation-section-chevron {
width: 16px;
height: 16px;
display: grid;
place-items: center;
flex: 0 0 auto;
svg {
width: 14px;
height: 14px;
}
path {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.conversation-section-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-section-list {
position: relative;
overflow: visible;
}
.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) {
display: none;
}
.conversation-section-virtual-content {
position: relative;
width: 100%;
}
.conversation-section-virtual-row {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 58px;
}
.conversation-section-empty {
padding: 10px 12px 12px 28px;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.conversation-item {
box-sizing: border-box;
position: relative;
width: 100%;
min-height: 58px;
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);
padding: 8px 10px 8px 13px;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.56);
}
&.active {
background: var(--wxex-brand-soft);
.conversation-item-active-mark {
background: var(--wxex-brand);
}
}
}
.conversation-item-active-mark {
position: absolute;
top: 10px;
bottom: 10px;
left: 0;
width: 3px;
border-radius: 0 999px 999px 0;
background: transparent;
}
.conversation-item-avatar {
box-sizing: border-box;
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font-size: 14px;
font-weight: 700;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
.conversation-item-body {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}
.conversation-item-name {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item-meta {
overflow: hidden;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-sidebar-account {
box-sizing: border-box;
flex: 0 0 auto;
border-top: 1px solid var(--wxex-border);
padding: 10px;
}
.conversation-sidebar-account .account-summary {
min-height: 58px;
padding: 8px;
}
.conversation-sidebar-account .account-summary-text {
flex: 1;
}
.sidebar {
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
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;
}
.sidebar-footer {
padding: 10px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.sidebar-btn {
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
color: #666;
&:hover {
color: #333;
}
}
.sidebar-status {
color: #07c160;
font-size: 12px;
}
.search-input {
width: 100%;
padding: 5px;
border-radius: 4px;
border: 1px solid #ccc;
-webkit-app-region: no-drag;
}
.date-range-selector {
display: flex;
gap: 5px;
margin-top: 8px;
justify-content: space-between;
}
.range-btn {
padding: 2px 6px;
font-size: 10px;
border: 1px solid #ccc;
border-radius: 10px;
background: #fff;
cursor: pointer;
color: #666;
flex: 1;
text-align: center;
&.active {
background: #07c160;
color: #fff;
border-color: #07c160;
}
}
.contact-list {
flex: 1;
overflow-y: auto;
}
.section-header {
padding: 8px 10px;
background-color: #f0f0f0;
font-size: 12px;
color: #666;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
user-select: none;
&:hover {
background-color: #e0e0e0;
}
.arrow {
margin-right: 5px;
font-size: 10px;
width: 12px;
}
}
.section-empty {
padding: 10px 14px 12px 27px;
color: #888;
font-size: 12px;
}
.contact-item {
padding: 10px;
display: flex;
align-items: center;
cursor: pointer;
&:hover {
background-color: var(--sidebar-hover);
}
&.active {
background-color: var(--sidebar-active);
}
}
.contact-avatar {
width: 40px;
height: 40px;
border-radius: 4px;
background-color: #ccc;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.message-avatar {
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.contact-info {
flex: 1;
overflow: hidden;
}
.contact-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.resizer {
width: 4px;
cursor: col-resize;
background-color: transparent;
border-right: 1px solid transparent;
transition:
background-color 0.2s,
border-color 0.2s;
z-index: 10;
}
.resizer:hover,
.resizer:active {
border-color: rgba(36, 122, 99, 0.22);
background-color: rgba(36, 122, 99, 0.06);
}
+951
View File
@@ -0,0 +1,951 @@
/* Export workspace */
.export-workspace {
display: grid;
grid-template-columns: 292px minmax(520px, 1fr) 360px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: var(--wxex-bg-main);
}
.export-contact-panel,
.export-config-panel,
.export-preview-panel {
min-width: 0;
min-height: 0;
background: var(--wxex-bg-elevated);
}
.export-contact-panel {
display: flex;
flex-direction: column;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
}
.export-panel-header {
padding: 20px 16px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.export-panel-title-row,
.export-section-heading,
.export-preview-heading,
.export-action-bar,
.export-target-path,
.export-media-master,
.export-account-summary {
display: flex;
align-items: center;
}
.export-panel-title-row {
justify-content: space-between;
gap: 8px;
margin-bottom: 14px;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 17px;
font-weight: 700;
}
}
.export-count-badge {
padding: 3px 8px;
border-radius: 5px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.export-search-field {
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
.export-filter-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
margin-top: 12px;
padding: 3px;
border-radius: 7px;
background: #e3e9e5;
button {
border: 0;
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/30px var(--wxex-font);
&.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
}
}
}
.export-contact-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
}
.export-contact-item {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
padding: 11px 16px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.58);
}
&.active {
border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
}
.export-contact-avatar,
.export-account-avatar,
.export-chat-avatar,
.export-preview-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-weight: 700;
}
.export-contact-avatar {
width: 38px;
height: 38px;
border-radius: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-contact-copy,
.export-account-summary > span:last-child {
display: grid;
min-width: 0;
gap: 2px;
strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.export-account-summary {
small.ready {
color: var(--wxex-success);
}
}
.export-account-summary {
gap: 9px;
padding: 14px 16px;
border: 0;
border-top: 1px solid var(--wxex-border);
background: transparent;
text-align: left;
cursor: pointer;
}
.export-account-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-config-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wxex-bg-main);
}
.export-config-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px 28px 28px;
}
.export-config-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
h1 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
p {
margin: 2px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
}
}
.export-chat-avatar {
width: 52px;
height: 52px;
border-radius: 10px;
font-size: 22px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-section {
margin-bottom: 25px;
h3 {
margin: 0 0 12px;
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 700;
}
}
.export-section-heading {
justify-content: space-between;
gap: 12px;
span {
color: var(--wxex-brand);
font-size: 12px;
}
}
.export-range-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
button {
border: 0;
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/30px var(--wxex-font);
border: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
line-height: 36px;
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
box-shadow: none;
}
}
}
.export-date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
padding: 12px;
border-radius: 8px;
background: #f0f3f0;
label {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
}
input {
width: 100%;
min-width: 0;
padding: 8px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
.export-kind-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 20px;
padding: 14px;
border-radius: 8px;
background: #f0f3f0;
}
.export-check-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
input {
width: 16px;
height: 16px;
accent-color: var(--wxex-brand);
}
&.unsupported {
color: var(--wxex-text-muted);
cursor: not-allowed;
input {
cursor: not-allowed;
}
}
}
.export-unsupported-hint {
display: inline-grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid #c57923;
border-radius: 50%;
color: #a85e13;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.export-name-mode-grid {
display: flex;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f0f3f0;
}
.export-name-mode-option {
display: flex;
align-items: center;
gap: 7px;
flex: 1;
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
input {
accent-color: var(--wxex-brand);
}
}
.export-media-master {
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
input {
width: 17px;
height: 17px;
accent-color: var(--wxex-brand);
}
}
.export-media-options {
display: grid;
gap: 2px;
margin-top: 8px;
padding: 9px 13px;
border-radius: 8px;
background: #f0f3f0;
&.disabled {
opacity: 0.52;
}
}
.export-resource-statuses {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
span {
padding: 3px 7px;
border-radius: 4px;
background: var(--wxex-brand-soft);
color: var(--wxex-success);
font-size: 10px;
}
}
.export-helper-text {
margin: 9px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
}
.export-format-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
button {
min-height: 78px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
&.active {
border: 2px solid var(--wxex-brand);
background: #f2f8f5;
}
}
strong {
display: block;
font-size: 13px;
}
small {
display: block;
margin-top: 6px;
color: var(--wxex-success);
font-size: 10px;
}
}
.export-html-options {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #f0f3f0;
color: var(--wxex-text-primary);
font-size: 12px;
input {
accent-color: var(--wxex-brand);
}
}
.export-save-section {
display: grid;
gap: 12px;
> label {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
input {
width: 100%;
min-width: 0;
padding: 8px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
outline: 0;
background: #fff;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
}
}
.export-target-path {
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-secondary);
font-size: 11px;
strong {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
padding: 5px 8px;
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 11px var(--wxex-font);
}
}
.export-action-bar {
flex: 0 0 58px;
gap: 8px;
padding: 0 22px;
border-top: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-ready-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wxex-success);
&.completed {
background: var(--wxex-brand);
}
}
.export-target-summary {
flex: 1;
min-width: 0;
margin-left: 14px;
overflow: hidden;
color: var(--wxex-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.export-reset-button {
border: 0;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-primary-button {
min-width: 132px;
padding: 10px 16px;
border: 0;
border-radius: 7px;
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 700 12px var(--wxex-font);
&:disabled {
cursor: default;
opacity: 0.55;
}
}
.export-preview-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--wxex-border);
background: #fbfcfb;
}
.export-preview-heading {
justify-content: space-between;
flex: 0 0 52px;
padding: 0 18px;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-primary);
font-size: 12px;
span {
color: var(--wxex-text-muted);
font-size: 11px;
}
}
.export-message-preview {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 14px;
}
.export-preview-date {
width: fit-content;
margin: 0 auto 18px;
padding: 4px 9px;
border-radius: 10px;
background: #e9eeeb;
color: var(--wxex-text-secondary);
font-size: 10px;
}
.export-preview-message {
display: flex;
align-items: flex-start;
gap: 8px;
width: min(100%, 620px);
margin: 0 auto 15px;
&.mine {
flex-direction: row-reverse;
.export-preview-bubble {
background: #95ec69;
}
}
&.system {
justify-content: center;
flex-direction: row;
.export-preview-avatar {
display: none;
}
.export-preview-bubble {
max-width: 92%;
padding: 5px 10px;
border: 0;
border-radius: 5px;
background: #e9eeeb;
color: var(--wxex-text-muted);
font-size: 11px;
text-align: center;
box-shadow: none;
small {
display: none;
}
}
}
}
.export-preview-avatar {
width: 30px;
height: 30px;
border-radius: 7px;
font-size: 11px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.export-preview-bubble {
max-width: 78%;
padding: 9px 10px;
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
line-height: 18px;
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
small {
display: block;
margin-bottom: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
}
.export-preview-stats {
flex: 0 0 auto;
display: grid;
gap: 12px;
padding: 18px;
border-top: 1px solid var(--wxex-border);
}
.export-task-center-button {
align-self: flex-start;
margin: 0 0 12px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 11px;
font: 600 12px/18px var(--wxex-font);
}
.export-task-center {
margin: 0 0 18px;
padding: 12px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: var(--wxex-bg-elevated);
}
.export-task-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 9px 0;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font-size: 12px;
&:first-of-type {
margin-top: 8px;
}
> span:first-child {
display: grid;
gap: 2px;
min-width: 0;
}
strong,
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: var(--wxex-text-primary);
}
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
padding: 4px 8px;
}
}
.export-task-progress {
position: relative;
width: 110px;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--wxex-border);
i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--wxex-brand);
}
b {
position: absolute;
top: 10px;
right: 0;
color: var(--wxex-text-muted);
font-size: 10px;
font-weight: 500;
}
}
.export-preview-panel .export-preview-stats:not(.export-preview-real-stats) {
display: none;
}
.export-preview-stats span,
.export-complete-summary span {
display: flex;
justify-content: space-between;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-preview-stats strong,
.export-complete-summary strong {
color: var(--wxex-text-primary);
font-weight: 600;
}
.export-job-state {
display: grid;
align-content: center;
gap: 12px;
height: 100%;
padding: 28px;
text-align: center;
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
ol {
display: grid;
gap: 10px;
margin: 8px 0;
padding: 0;
list-style: none;
text-align: left;
}
li {
color: var(--wxex-text-muted);
font-size: 12px;
&::before {
display: inline-grid;
width: 16px;
height: 16px;
margin-right: 8px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
content: '';
vertical-align: -3px;
}
&.done {
color: var(--wxex-success);
&::before {
border-color: var(--wxex-success);
background: var(--wxex-success);
content: '';
color: #fff;
font-size: 10px;
}
}
&.current {
color: var(--wxex-text-primary);
font-weight: 600;
&::before {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
box-shadow: inset 0 0 0 4px #fff;
}
}
}
> strong {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 500;
}
}
.export-progress-bar {
height: 6px;
overflow: hidden;
border-radius: 4px;
background: #e2e9e4;
span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--wxex-brand);
transition: width 0.2s ease;
}
}
.export-job-state > strong {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 500;
}
.export-cancel-button,
.export-open-folder-button {
padding: 10px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-success-icon {
width: 58px;
height: 58px;
margin: 0 auto 4px;
border: 5px solid var(--wxex-brand-soft);
border-radius: 50%;
background: #fff;
color: var(--wxex-brand);
font-size: 30px;
line-height: 48px;
}
.export-complete-summary {
display: grid;
gap: 12px;
margin: 12px 0;
padding: 14px;
border-radius: 8px;
background: #eef3ef;
text-align: left;
}
@media (max-width: 1100px) {
.export-workspace {
grid-template-columns: 248px minmax(460px, 1fr);
}
.export-preview-panel {
display: none;
}
}
+348
View File
@@ -0,0 +1,348 @@
:root {
--bg-color: #f5f5f5;
--sidebar-bg: #e7e7e7;
--sidebar-hover: #d6d6d6;
--sidebar-active: #c6c6c6;
--chat-bg: #f5f5f5;
--message-bg-user: #95ec69;
--message-bg-other: #ffffff;
--text-color: #000000;
--border-color: #dcdcdc;
}
body {
margin: 0;
padding: 0;
font-family: var(--wxex-font);
background-color: var(--wxex-bg-app);
color: var(--wxex-text-primary);
height: 100vh;
overflow: hidden;
}
#root {
height: 100%;
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: 12px;
background: transparent;
-webkit-app-region: no-drag;
img {
display: block;
width: 42px;
height: 42px;
border-radius: 12px;
}
}
.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);
&:hover {
background: rgba(255, 255, 255, 0.46);
color: var(--wxex-text-primary);
}
&.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.primary-nav-mark {
width: 24px;
height: 24px;
display: grid;
place-items: center;
svg {
width: 22px;
height: 22px;
}
path,
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);
font-family: var(--wxex-font);
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.72);
}
}
.account-summary.compact {
justify-content: center;
cursor: pointer;
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;
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);
&.ready {
background: var(--wxex-success);
}
}
.account-summary:not(.compact) .account-summary-avatar .account-summary-status {
display: none;
}
.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;
}
.account-summary-state {
overflow: hidden;
display: flex;
align-items: center;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-summary-state-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 999px;
background: var(--wxex-text-muted);
&.ready {
background: var(--wxex-success);
}
}
.account-summary-settings {
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
svg {
width: 17px;
height: 17px;
}
path {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.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;
h2 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
p {
margin: 0;
font-size: 13px;
line-height: 20px;
}
}
.app-page-placeholder-eyebrow {
margin-bottom: 8px;
color: var(--wxex-brand);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.app-container {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
align-items: stretch;
overflow: hidden;
}
+16
View File
@@ -0,0 +1,16 @@
@use './_tokens';
@use './_base';
@use './foundation';
@use './conversation';
@use './chat';
@use './rich-message';
@use './shell-overlays';
@use './reports-core';
@use './settings';
@use './api';
@use './reports-templates';
@use './export';
@use './search';
@use './archive';
@use './settings-advanced';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
@media (max-width: 1100px) {
.api-center-layout {
grid-template-columns: minmax(0, 1fr) 280px;
}
.api-main-scroll {
padding: 18px;
}
.api-header-actions {
max-width: 260px;
justify-content: flex-end;
}
.api-endpoint-head,
.api-endpoint-row {
grid-template-columns: 56px minmax(110px, 1fr) minmax(100px, 1fr) 78px;
gap: 7px;
padding: 10px;
}
.api-flow {
overflow-x: auto;
justify-content: flex-start;
}
}
.report-history-list-title {
padding: 4px 4px 8px;
color: var(--wxex-text-secondary);
font: 700 13px/18px var(--wxex-font);
}
.report-history-group {
margin: 0 0 10px;
}
.report-history-group h3 {
margin: 0;
padding: 6px 4px;
color: var(--wxex-text-muted);
font: 700 12px/16px var(--wxex-font);
}
.report-history-item {
position: relative;
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
gap: 10px;
padding: 10px 54px 10px 12px;
border: 0;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-primary);
text-align: left;
cursor: pointer;
font-family: var(--wxex-font);
}
.report-history-item:hover {
background: rgba(255, 255, 255, 0.62);
}
.report-history-item.active {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-history-item.active::before {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 4px;
background: var(--wxex-brand);
content: '';
}
.report-history-avatar {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
font: 700 14px/1 var(--wxex-font);
}
.report-history-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.report-history-text {
display: grid;
flex: 1 1 auto;
min-width: 0;
gap: 2px;
}
.report-history-text b,
.report-history-text small,
.report-history-text em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.report-history-text b {
color: inherit;
font: 600 13px/18px var(--wxex-font);
}
.report-history-text small,
.report-history-text em {
color: var(--wxex-text-muted);
font: normal 12px/16px var(--wxex-font);
}
.report-history-delete {
position: absolute;
right: 10px;
top: 50%;
display: grid;
width: 38px;
height: 26px;
place-items: center;
border: 1px solid rgba(198, 72, 72, 0.22);
border-radius: var(--wxex-radius-sm);
background: rgba(198, 72, 72, 0.06);
color: #a64242;
cursor: pointer;
font: 600 12px/16px var(--wxex-font);
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.12s ease;
}
.report-history-item:hover .report-history-delete,
.report-history-delete:focus {
opacity: 1;
}
.report-history-empty {
display: grid;
gap: 10px;
padding: 18px 8px;
color: var(--wxex-text-muted);
font: 13px/18px var(--wxex-font);
}
.report-history-empty button,
.report-center-empty button {
justify-self: start;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-history-account {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--wxex-border);
}
.report-delete-confirm {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
background: rgba(32, 39, 36, 0.28);
}
.report-delete-confirm-card {
display: grid;
width: min(360px, calc(100vw - 48px));
gap: 12px;
padding: 18px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-main);
box-shadow: var(--wxex-shadow-popover);
}
.report-delete-confirm-card h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-delete-confirm-card p {
margin: 0;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.report-delete-confirm-card > div {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.report-delete-confirm-card button {
min-height: 32px;
padding: 0 14px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-delete-confirm-card button.danger {
border-color: #a64242;
background: #a64242;
color: #fff;
}
.report-delete-error {
color: #a64242 !important;
}
.report-viewer {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
background: #f2f1ee;
}
.report-viewer-header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-viewer-header h1 {
font: 700 20px/27px var(--wxex-font);
}
.report-viewer-toolbar {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.report-viewer-toolbar button,
.report-zoom-bar button,
.report-more-popover button,
.report-settings-section button {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-viewer-toolbar button.primary {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-viewer-toolbar button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.72;
}
.report-more-menu {
position: relative;
}
.report-more-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 8;
min-width: 128px;
padding: 6px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-more-popover button {
width: 100%;
border: 0;
text-align: left;
}
.report-viewer-status {
flex: 0 0 auto;
padding: 8px 22px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
font: 12px/18px var(--wxex-font);
}
.report-viewer-stage {
display: block;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: auto;
padding: 22px;
}
.report-canvas {
width: max-content;
margin: 0 auto;
padding: 0;
background: #fff;
box-shadow: 0 8px 24px rgba(32, 39, 36, 0.14);
}
.report-canvas img {
display: block;
max-width: none;
height: auto;
}
.report-zoom-bar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-main);
}
.report-zoom-bar span {
min-width: 48px;
color: var(--wxex-text-secondary);
text-align: center;
font: 12px/18px var(--wxex-font);
}
.report-center-empty {
display: grid;
align-self: center;
justify-self: center;
max-width: 360px;
gap: 12px;
margin: auto;
color: var(--wxex-text-secondary);
text-align: center;
}
.report-center-empty-icon {
display: grid;
width: 54px;
height: 54px;
place-items: center;
justify-self: center;
border: 1px solid rgba(104, 110, 220, 0.18);
border-radius: 18px;
background: var(--wxex-ai-soft);
color: var(--wxex-ai);
}
.report-center-empty-icon svg {
width: 28px;
height: 28px;
}
.report-center-empty-icon path {
fill: currentColor;
}
.report-center-empty h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-center-empty p {
margin: 0;
font: 13px/20px var(--wxex-font);
}
.report-center-empty button {
justify-self: center;
}
.report-settings-panel {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--wxex-border);
background: #f7f9f8;
}
.report-settings-panel header {
flex: 0 0 auto;
padding: 20px 18px 14px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-panel header h2 {
font: 700 16px/22px var(--wxex-font);
}
.report-settings-section {
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--wxex-border);
}
.report-settings-section h3 {
margin: 0 0 8px;
color: var(--wxex-text-primary);
font: 700 14px/20px var(--wxex-font);
}
.report-settings-section p,
.report-settings-section code {
margin: 0 0 10px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-settings-section code {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
padding: 8px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
}
.report-settings-section.muted {
color: var(--wxex-text-muted);
}
.report-export-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.report-export-list div {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-export-list b {
min-width: 0;
color: var(--wxex-text-primary);
font-weight: 600;
overflow-wrap: anywhere;
text-align: right;
}
.report-generation-log {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.report-generation-log li {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.report-generation-log li > span {
color: var(--wxex-brand);
font-weight: 700;
}
.report-generation-log b,
.report-generation-log time,
.report-generation-log small {
display: block;
}
.report-generation-log b {
color: var(--wxex-text-primary);
font-weight: 600;
}
.report-generation-log time {
color: var(--wxex-text-muted);
font-size: 11px;
}
.report-generation-log small {
color: var(--wxex-text-secondary);
font: 600 11px/16px var(--wxex-font);
}
@media (max-width: 1120px) {
.report-center-page {
grid-template-columns: 268px minmax(360px, 1fr) 280px;
}
.report-viewer-header {
flex-direction: column;
}
}
/* ============================================================ */
/* 日报模板选择器 + 预览器 */
/* ============================================================ */
.report-section-desc {
margin: 0 0 12px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.report-template-list {
display: grid;
gap: 10px;
}
.report-template-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1.5px solid var(--wxex-border);
border-radius: 12px;
background: #fff;
transition:
border-color 0.2s,
background 0.2s;
}
.report-template-item.active {
border-color: var(--wxex-primary, #07c160);
background: #f0fbf3;
}
.report-template-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.report-template-item > label {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
min-width: 0;
}
.report-template-item input[type='radio'] {
accent-color: var(--wxex-primary, #07c160);
}
.report-template-body {
flex: 1;
min-width: 0;
}
.report-template-title {
font-size: 14px;
font-weight: 700;
color: var(--wxex-text-primary, #1f2933);
}
.report-template-tagline {
margin-top: 4px;
font-size: 12px;
color: var(--wxex-text-secondary, #485465);
line-height: 1.5;
}
.report-template-preview-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary, #1f2933);
cursor: pointer;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
}
.report-template-preview-btn:hover {
background: var(--wxex-primary, #07c160);
color: #fff;
border-color: var(--wxex-primary, #07c160);
}
/* 预览遮罩 */
.report-template-preview-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.report-template-preview-card {
background: #f3f5f7;
border-radius: 18px;
padding: 18px;
width: min(380px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
}
.report-template-preview-card h4 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 800;
}
.report-template-preview-card p.muted {
margin: 0 0 14px;
font-size: 12px;
color: var(--wxex-text-secondary, #485465);
line-height: 1.5;
}
.report-template-preview-frame {
display: grid;
gap: 8px;
background: #fff;
border-radius: 14px;
padding: 12px;
border: 1px solid var(--wxex-border);
}
.fake-card {
background: #f7faf9;
border-radius: 10px;
padding: 10px 12px;
}
.fake-hero {
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
}
.fake-title {
font-size: 14px;
font-weight: 800;
color: #076c39;
}
.fake-sub {
margin-top: 2px;
font-size: 11px;
color: #485465;
}
.fake-section {
display: flex;
align-items: center;
gap: 8px;
}
.fake-bar {
width: 4px;
height: 14px;
border-radius: 2px;
background: var(--wxex-primary, #07c160);
}
.fake-section-title {
font-size: 12px;
font-weight: 700;
color: #1f2933;
}
.report-template-preview-close {
margin-top: 14px;
width: 100%;
padding: 10px;
border-radius: 10px;
border: none;
background: var(--wxex-primary, #07c160);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.settings-auto-login-card {
padding: 18px 20px;
}
.settings-auto-login-card label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
cursor: pointer;
}
.settings-auto-login-card label > span {
display: grid;
gap: 4px;
}
.settings-auto-login-card b {
color: var(--wxex-text-primary);
font: 700 13px/19px var(--wxex-font);
}
.settings-auto-login-card small {
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.settings-auto-login-card input {
width: 18px;
height: 18px;
accent-color: var(--wxex-brand);
}
.settings-recall-card {
padding: 18px 20px;
}
.settings-recall-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
gap: 18px;
align-items: center;
}
.settings-recall-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-width: 0;
cursor: pointer;
}
.settings-recall-option span {
display: grid;
gap: 4px;
}
.settings-recall-option b {
color: var(--wxex-text-primary);
font: 700 13px/19px var(--wxex-font);
}
.settings-recall-option small {
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.settings-recall-option input {
width: 18px;
height: 18px;
flex: 0 0 auto;
accent-color: var(--wxex-brand);
}
.settings-recall-notice {
display: grid;
gap: 3px;
padding-left: 14px;
border-left: 2px solid #e5b45f;
color: #765326;
font: 12px/18px var(--wxex-font);
}
.settings-recall-notice strong {
font-weight: 700;
}
.settings-recall-notice span {
color: #8a6a3e;
}
.api-upload-test-row {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.api-upload-test-row button {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.api-upload-test-row span {
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
+331
View File
@@ -0,0 +1,331 @@
/* Voice Player */
.voice-message {
cursor: pointer;
user-select: none;
}
.voice-loading {
opacity: 0.6;
}
.voice-error {
opacity: 1;
}
.voice-loading-text {
font-size: 12px;
color: var(--wxex-text-muted);
}
.voice-error-text {
color: var(--wxex-text-secondary);
}
.voice-duration {
font-size: 12px;
margin-left: 4px;
min-width: 32px;
}
.voice-icon.playing {
background: rgba(36, 122, 99, 0.18);
}
/* Rich Message Bubbles */
.location-message {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
.location-icon {
box-sizing: border-box;
min-width: 34px;
padding: 4px 6px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.location-info {
flex: 1;
overflow: hidden;
}
.location-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-label {
font-size: 12px;
color: var(--wxex-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.location-coords {
font-size: 11px;
color: var(--wxex-text-muted);
margin-top: 2px;
}
}
.wechat-message-row.mine {
.location-message {
flex-direction: row-reverse;
}
}
.card-message {
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 240px;
}
.card-avatar {
width: 40px;
height: 40px;
border-radius: 6px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 500;
flex-shrink: 0;
}
.card-info {
flex: 1;
overflow: hidden;
}
.card-nickname {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-username {
font-size: 12px;
color: #666;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: #07c160;
}
}
.share-message {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
min-width: 180px;
max-width: 280px;
}
.share-appname {
font-size: 11px;
color: #999;
}
.share-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.share-desc {
font-size: 12px;
color: #666;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.share-url {
font-size: 11px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-program-message {
width: min(280px, 48vw);
overflow: hidden;
color: var(--wxex-text-primary);
}
.mini-program-title {
margin-bottom: 8px;
font-size: 14px;
line-height: 20px;
word-break: break-word;
}
.mini-program-description {
margin: -4px 0 8px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.mini-program-preview {
.image-bubble,
.image-content {
width: 100%;
max-width: 100%;
max-height: 220px;
border-radius: 4px;
}
.image-content {
object-fit: cover;
}
.image-actions {
display: none;
}
.image-content.image-fallback {
width: 64px;
height: 64px;
margin: 28px auto;
border-radius: 8px;
object-fit: cover;
}
}
.mini-program-inline-image {
display: block;
width: 100%;
max-height: 220px;
border-radius: 4px;
object-fit: cover;
cursor: zoom-in;
}
.mini-program-icon {
display: block;
width: 52px;
height: 52px;
margin: 8px 0;
border-radius: 6px;
object-fit: cover;
}
.mini-program-footer {
display: flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.red-packet-message {
width: min(280px, 48vw);
overflow: hidden;
border-radius: 6px;
background: #fa9d3b;
color: #fff;
&.is-clickable {
cursor: pointer;
}
}
.red-packet-main {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 12px 14px;
}
.red-packet-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border: 2px solid rgba(255, 239, 170, 0.92);
border-radius: 50%;
color: #fff1a8;
font-size: 18px;
font-weight: 700;
}
.red-packet-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.red-packet-copy strong {
overflow: hidden;
font-size: 15px;
font-weight: 500;
line-height: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.red-packet-copy small {
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 18px;
}
.red-packet-footer {
padding: 5px 14px;
background: #fff;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.voip-message {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.voip-icon {
box-sizing: border-box;
padding: 3px 6px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
font-weight: 600;
}
.voip-status {
font-size: 13px;
}
@@ -21,25 +21,22 @@
padding: 14px 22px 12px;
border-bottom: 1px solid var(--wxex-border);
background: rgba(250, 251, 250, 0.94);
}
.ai-search-header h1,
.ai-search-header p {
h1 {
margin: 0;
}
.ai-search-header h1 {
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
.ai-search-header p {
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
}
.ai-search-kicker {
display: block;
@@ -57,9 +54,8 @@
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.ai-search-model-status > span:first-child {
> span:first-child {
width: 8px;
height: 8px;
flex: 0 0 auto;
@@ -67,20 +63,29 @@
background: var(--wxex-text-muted);
}
.ai-search-model-status > span.ready {
> span {
&.ready {
background: var(--wxex-success);
}
.ai-search-model-status > span.warning {
&.warning {
background: var(--wxex-warning);
}
}
.ai-search-model-status > span:nth-child(2) {
> span:nth-child(2) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
padding: 5px 8px;
color: var(--wxex-brand);
white-space: nowrap;
}
}
.ai-search-model-status button,
.ai-search-result-actions button,
.ai-search-composer button,
@@ -93,12 +98,6 @@
font: inherit;
}
.ai-search-model-status button {
padding: 5px 8px;
color: var(--wxex-brand);
white-space: nowrap;
}
.ai-search-grid {
display: grid;
flex: 1;
@@ -134,27 +133,28 @@
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.ai-search-panel-heading div {
div {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-search-panel-heading span:first-child {
span:first-child {
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
.ai-search-panel-heading strong {
strong {
color: var(--wxex-text-primary);
font-size: 14px;
font-weight: 700;
line-height: 20px;
}
}
.ai-search-local-badge,
.ai-search-count-badge {
border-radius: var(--wxex-radius-sm);
@@ -179,33 +179,17 @@
padding: 3px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
}
.ai-search-scope-toggle button,
.ai-search-range-grid button,
.ai-search-history button {
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
}
.ai-search-scope-toggle button {
button {
padding: 7px 4px;
font-size: 11px;
}
.ai-search-scope-toggle button.active,
.ai-search-range-grid button.active {
&.active {
background: var(--wxex-brand);
color: var(--wxex-brand);
color: #fff;
font-weight: 700;
}
.ai-search-scope-toggle button.active {
color: #fff;
}
}
.ai-search-scope-help {
@@ -235,16 +219,15 @@
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
}
.ai-search-filter-input svg {
svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: var(--wxex-text-muted);
}
.ai-search-filter-input input {
input {
width: 100%;
min-width: 0;
border: 0;
@@ -254,6 +237,7 @@
font: inherit;
font-size: 11px;
}
}
.ai-search-contact-list {
display: flex;
@@ -277,12 +261,12 @@
cursor: pointer;
font: inherit;
text-align: left;
}
.ai-search-contact:hover,
.ai-search-contact.active {
&:hover,
&.active {
background: var(--wxex-brand-soft);
}
}
.ai-search-contact-avatar {
width: 28px;
@@ -296,13 +280,13 @@
color: var(--wxex-brand);
font-size: 12px;
font-weight: 700;
}
.ai-search-contact-avatar img {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ai-search-contact > span:last-child {
display: flex;
@@ -337,6 +321,31 @@
background: var(--wxex-bg-elevated);
grid-template-columns: 1fr 1fr;
margin-bottom: 16px;
button {
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
min-height: 34px;
padding: 7px 4px;
border: 1px solid transparent;
font-size: 11px;
&:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
&.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
box-shadow: 0 1px 3px rgba(34, 89, 74, 0.2);
}
}
}
.ai-search-field-heading {
@@ -356,26 +365,6 @@
font-weight: 700;
}
.ai-search-range-grid button {
min-height: 34px;
padding: 7px 4px;
border: 1px solid transparent;
font-size: 11px;
}
.ai-search-range-grid button:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
.ai-search-range-grid button.active,
.ai-search-range-grid button.active:hover {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
box-shadow: 0 1px 3px rgba(34, 89, 74, 0.2);
}
.ai-search-filter-note {
display: flex;
gap: 6px;
@@ -386,40 +375,46 @@
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 16px;
}
.ai-search-filter-note span {
span {
color: var(--wxex-success);
}
}
.ai-search-history {
margin-top: 18px;
}
.ai-search-history button {
button {
min-width: 0;
padding: 5px 0;
overflow: hidden;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
}
.ai-search-history button:hover {
&:hover {
color: var(--wxex-brand);
}
}
}
.ai-search-history-item {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.ai-search-history-item > button:first-child {
> button:first-child {
flex: 1;
}
}
.ai-search-history-delete {
width: 20px;
@@ -434,12 +429,12 @@
font-size: 16px !important;
line-height: 18px;
text-align: center !important;
}
.ai-search-history-delete:hover {
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-danger, #b34d46) !important;
}
}
.ai-search-main {
display: flex;
@@ -468,6 +463,22 @@
padding: 48px 24px;
flex-direction: column;
text-align: center;
h2 {
margin: 7px 0 8px;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
}
p {
max-width: 440px;
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 20px;
}
}
.ai-search-empty-mark {
@@ -482,24 +493,18 @@
font-size: 28px;
}
.ai-search-empty h2,
.ai-search-loading h2,
.ai-search-insufficient h2 {
margin: 7px 0 8px;
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
}
.ai-search-insufficient {
button {
margin-top: 20px;
padding: 8px 12px;
font-size: 11px;
.ai-search-empty p,
.ai-search-loading p,
.ai-search-insufficient p {
max-width: 440px;
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 20px;
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
}
.ai-search-prompts {
@@ -508,9 +513,8 @@
justify-content: center;
gap: 7px;
margin-top: 22px;
}
.ai-search-prompts button {
button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
@@ -519,12 +523,13 @@
cursor: pointer;
font: inherit;
font-size: 11px;
}
.ai-search-prompts button:hover {
&:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
}
}
.ai-search-spinner {
width: 42px;
@@ -550,15 +555,16 @@
color: var(--wxex-text-muted);
font-size: 11px;
text-align: left;
}
.ai-search-loading-steps .done {
.done {
color: var(--wxex-success);
}
.ai-search-loading-steps .active {
.active {
color: var(--wxex-brand);
font-weight: 700;
}
}
.ai-search-result {
width: min(820px, calc(100% - 48px));
@@ -573,9 +579,8 @@
gap: 18px;
padding-bottom: 20px;
border-bottom: 1px solid var(--wxex-border);
}
.ai-search-result-header h2 {
h2 {
margin: 6px 0 5px;
color: var(--wxex-text-primary);
font-size: 18px;
@@ -583,12 +588,13 @@
line-height: 26px;
}
.ai-search-result-header p {
p {
margin: 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
}
.ai-search-result-actions {
display: flex;
@@ -596,20 +602,18 @@
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.ai-search-result-actions button {
button {
padding: 7px 9px;
font-size: 11px;
}
.ai-search-result-actions button.primary,
.ai-search-composer button.primary,
.ai-search-insufficient button.primary {
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
}
.ai-search-summary-block {
margin-top: 22px;
@@ -656,12 +660,6 @@
font-weight: 700;
}
.ai-search-insufficient button {
margin-top: 20px;
padding: 8px 12px;
font-size: 11px;
}
.ai-search-composer {
padding: 12px 18px 14px;
border-top: 1px solid var(--wxex-border);
@@ -678,23 +676,25 @@
line-height: 15px;
}
.ai-search-composer-meta strong {
.ai-search-composer-meta {
strong {
color: var(--wxex-brand);
font-weight: 700;
}
.ai-search-composer-meta em {
em {
font-style: normal;
color: var(--wxex-text-secondary);
}
}
.ai-search-composer-row {
display: flex;
align-items: flex-end;
gap: 8px;
margin-top: 7px;
}
.ai-search-composer textarea {
textarea {
width: 100%;
min-width: 0;
resize: none;
@@ -707,27 +707,37 @@
font: inherit;
font-size: 12px;
line-height: 18px;
}
.ai-search-composer textarea:focus {
&:focus {
border-color: var(--wxex-brand);
}
.ai-search-composer button {
}
button {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
white-space: nowrap;
font-size: 11px;
}
.ai-search-composer button span {
span {
font-size: 16px;
line-height: 12px;
}
.ai-search-composer button:disabled {
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
&:disabled {
opacity: 0.6;
cursor: wait;
}
}
}
.ai-search-composer-foot {
justify-content: space-between;
margin-top: 6px;
@@ -1,46 +0,0 @@
.settings-debug-card {
display: grid;
gap: 14px;
}
.settings-debug-card > label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.settings-debug-card > label > span {
display: grid;
gap: 5px;
}
.settings-debug-card b {
color: var(--wxex-text-primary);
font-size: 13px;
}
.settings-debug-card small {
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 18px;
}
.settings-debug-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--wxex-border);
}
.settings-debug-actions button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
font: inherit;
font-size: 11px;
}
@@ -0,0 +1,46 @@
.settings-debug-card {
display: grid;
gap: 14px;
> label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
> span {
display: grid;
gap: 5px;
}
}
b {
color: var(--wxex-text-primary);
font-size: 13px;
}
small {
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 18px;
}
}
.settings-debug-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--wxex-border);
button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
font: inherit;
font-size: 11px;
}
}
File diff suppressed because it is too large Load Diff
+446
View File
@@ -0,0 +1,446 @@
/* Sidebar 自助卡片 + 入口 */
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
background-color: #f3f4f5;
transition: background-color 0.15s ease;
user-select: none;
&:hover {
background-color: #e6e9eb;
}
}
.sidebar-self-avatar {
width: 36px;
height: 36px;
border-radius: 6px;
background-color: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.sidebar-self-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.sidebar-self-nickname {
font-size: 13px;
font-weight: 500;
color: #1f2429;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-self-wxid {
font-size: 11px;
color: #8a9298;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-self-arrow {
color: #8a9298;
font-size: 18px;
flex-shrink: 0;
}
/* 启动自动连接 Splash */
.boot-splash {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: linear-gradient(180deg, #f5f7f8 0%, #eceff1 100%);
z-index: 2000;
animation: boot-splash-fade-in 0.2s ease-out;
}
@keyframes boot-splash-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.boot-splash-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(7, 193, 96, 0.18);
border-top-color: #07c160;
animation: boot-splash-spin 0.9s linear infinite;
}
@keyframes boot-splash-spin {
to {
transform: rotate(360deg);
}
}
.boot-splash-title {
font-size: 15px;
font-weight: 500;
color: #1f2429;
}
.boot-splash-subtitle {
font-size: 12px;
color: #6f767c;
}
.boot-splash-detail {
min-height: 18px;
font-size: 12px;
color: #8a9298;
}
.boot-splash-progress {
width: min(320px, 72vw);
height: 6px;
margin-top: 2px;
overflow: hidden;
border-radius: 999px;
background: rgba(7, 193, 96, 0.12);
}
.boot-splash-progress-bar {
height: 100%;
border-radius: inherit;
background: #07c160;
transition: width 0.18s ease-out;
}
/* 设置面板 */
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(15, 21, 26, 0.45);
backdrop-filter: blur(2px);
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
animation: settings-fade-in 0.16s ease-out;
}
@keyframes settings-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.settings-modal {
width: min(560px, 92vw);
max-height: 84vh;
background: #fff;
border-radius: 12px;
box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
animation: settings-pop-in 0.18s ease-out;
}
@keyframes settings-pop-in {
from {
transform: translateY(8px) scale(0.98);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 22px;
border-bottom: 1px solid #ececec;
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
color: #1f2429;
}
}
.settings-close {
background: transparent;
border: 0;
color: #7d858a;
font-size: 26px;
line-height: 1;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: #f0f2f4;
color: #1f2429;
}
}
.settings-body {
padding: 12px 22px 22px;
overflow-y: auto;
}
.settings-section {
margin-top: 14px;
padding: 14px 16px;
border-radius: 10px;
background: #fafbfc;
border: 1px solid #ececec;
}
.settings-section:first-child {
margin-top: 6px;
}
.settings-section-title {
font-size: 12px;
font-weight: 600;
color: #6f767c;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 10px;
}
.settings-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.settings-row:first-child {
margin-top: 0;
}
.settings-input {
flex: 1;
min-width: 0;
padding: 8px 11px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
font-size: 13px;
color: #1f2429;
font-family: 'SF Mono', Menlo, Consolas, monospace;
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.settings-input:focus {
border-color: #07c160;
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
}
.settings-input-half {
flex: 0 1 140px;
}
.settings-input-quarter {
flex: 0 1 90px;
}
.settings-btn {
padding: 7px 14px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
color: #30383d;
cursor: pointer;
font-size: 13px;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
border-color: #07c160;
color: #078f49;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.settings-btn-primary {
background: #07c160;
color: #fff;
border-color: #07c160;
&:hover:not(:disabled) {
background: #06ad56;
color: #fff;
border-color: #06ad56;
}
}
.settings-hint {
margin-top: 10px;
font-size: 11px;
color: #8a9298;
line-height: 1.6;
}
.settings-hint code {
background: #eef0f2;
padding: 1px 5px;
border-radius: 3px;
font-size: 10.5px;
}
.settings-status {
font-size: 12px;
color: #59636a;
}
.settings-status.ok {
color: #078f49;
}
.settings-status.fail {
color: #c73737;
}
.settings-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #30383d;
input {
width: 16px;
height: 16px;
accent-color: #07c160;
cursor: pointer;
}
}
.settings-self {
display: flex;
align-items: center;
gap: 12px;
}
.settings-self-avatar {
width: 56px;
height: 56px;
border-radius: 10px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 500;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.settings-self-info {
flex: 1;
min-width: 0;
}
.settings-self-nickname {
font-size: 15px;
font-weight: 600;
color: #1f2429;
}
.settings-self-wxid {
font-size: 12px;
color: #6f767c;
margin-top: 2px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.settings-self-account {
font-size: 11px;
color: #8a9298;
margin-top: 2px;
word-break: break-all;
}
.settings-self-empty {
font-size: 13px;
color: #8a9298;
}
.settings-path {
display: inline-block;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px;
background: #eef0f2;
padding: 3px 7px;
border-radius: 4px;
color: #30383d;
word-break: break-all;
max-width: 100%;
}
.app-toast {
position: fixed;
top: 18px;
left: calc(var(--wxex-nav-width) + 50%);
z-index: 40;
transform: translateX(-50%);
padding: 9px 14px;
border: 1px solid rgba(198, 134, 53, 0.35);
border-radius: var(--wxex-radius-md);
background: #fff9ef;
color: #7b4b14;
font: 13px/18px var(--wxex-font);
box-shadow: var(--wxex-shadow-popover);
}