Merge branch 'main' into fix-export-excel-columns
This commit is contained in:
48 files changed
+6566
-1002
No files matched your search
+30
-24
@@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
@@ -429,7 +430,7 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
@@ -590,9 +591,13 @@ function App() {
|
||||
<div className="agreement-notice">
|
||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||
<span className="agreement-notice-link">
|
||||
我们唯一的官方网站:
|
||||
官方网站:
|
||||
<a href="https://weflow.top" target="_blank" rel="noreferrer">
|
||||
https://weflow.top
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||
https://github.com/hicccc77/WeFlow
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -607,7 +612,7 @@ function App() {
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||
|
||||
<h4>4. 隐私保护</h4>
|
||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
@@ -665,30 +670,30 @@ function App() {
|
||||
)}
|
||||
|
||||
{showWaylandWarning && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>环境兼容性提示 (Wayland)</h2>
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>环境兼容性提示 (Wayland)</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||
<br />
|
||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||
<br />
|
||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
@@ -736,6 +741,7 @@ function App() {
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
|
||||
@@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
@@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
@@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<div className="form-group media-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出媒体内容</label>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="media-default-grid">
|
||||
@@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({
|
||||
/>
|
||||
表情包
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.files}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, files: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,44 +8,9 @@ import {
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import { drawPatternBackground } from '../utils/reportExport'
|
||||
import './AnnualReportWindow.scss'
|
||||
|
||||
// SVG 背景图案 (用于导出)
|
||||
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
// 绘制 SVG 图案背景到 canvas
|
||||
const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => {
|
||||
// 先填充背景色
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// 加载 SVG 图案
|
||||
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
// 平铺绘制图案
|
||||
const pattern = ctx.createPattern(img, 'repeat')
|
||||
if (pattern) {
|
||||
ctx.fillStyle = pattern
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
interface TopContact {
|
||||
username: string
|
||||
displayName: string
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
.biz-account-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-secondary); // 对齐会话列表背景
|
||||
|
||||
.biz-loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.biz-account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--primary-light) !important;
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 13px; // 补偿 border-left
|
||||
}
|
||||
|
||||
&.pay-account {
|
||||
background-color: var(--bg-primary);
|
||||
&.active {
|
||||
background-color: var(--primary-light) !important;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.biz-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px; // 对齐会话列表头像圆角
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.biz-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.biz-info-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.biz-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.biz-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.biz-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
margin-top: 2px;
|
||||
|
||||
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
|
||||
&.type-sub { color: var(--primary); background: var(--primary-light); }
|
||||
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
|
||||
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.biz-main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary); // 对齐聊天页背景
|
||||
|
||||
.main-header {
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--card-bg);
|
||||
flex-shrink: 0;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--bg-tertiary); // 对齐聊天背景色
|
||||
|
||||
.messages-wrapper {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px; // 减小间距,因为有了 time-divider
|
||||
}
|
||||
}
|
||||
|
||||
.time-divider {
|
||||
text-align: center;
|
||||
margin: 16px 0 8px;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// 占位状态:对齐 Chat 页面风格
|
||||
.biz-no-record-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
.no-record-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.5;
|
||||
|
||||
svg { width: 32px; height: 32px; }
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.biz-loading-more {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
|
||||
.pay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.pay-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.pay-icon-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #07c160;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-title {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pay-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pay-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.article-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
|
||||
.main-article {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
.article-cover {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.article-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||
|
||||
.article-title {
|
||||
color: white;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-digest {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sub-articles {
|
||||
.sub-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background-color: var(--bg-hover); }
|
||||
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
padding-right: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sub-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.biz-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
background: var(--bg-tertiary); // 对齐 Chat 页面空白背景
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg { width: 40px; height: 40px; }
|
||||
}
|
||||
|
||||
p { color: var(--text-tertiary); font-size: 14px; }
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||
import './BizPage.scss';
|
||||
|
||||
export interface BizAccount {
|
||||
username: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
type: string;
|
||||
last_time: number;
|
||||
formatted_last_time: string;
|
||||
}
|
||||
|
||||
export const BizAccountList: React.FC<{
|
||||
onSelect: (account: BizAccount) => void;
|
||||
selectedUsername?: string;
|
||||
searchKeyword?: string;
|
||||
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
|
||||
const [accounts, setAccounts] = useState<BizAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [myWxid, setMyWxid] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const initWxid = async () => {
|
||||
try {
|
||||
const wxid = await window.electronAPI.config.get('myWxid');
|
||||
if (wxid) {
|
||||
setMyWxid(wxid as string);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("获取 myWxid 失败:", e);
|
||||
}
|
||||
};
|
||||
initWxid().then(_r => { });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
if (!myWxid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||
setAccounts(res || []);
|
||||
} catch (err) {
|
||||
console.error('获取服务号列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetch().then(_r => { } );
|
||||
}, [myWxid]);
|
||||
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = accounts;
|
||||
if (searchKeyword) {
|
||||
const q = searchKeyword.toLowerCase();
|
||||
result = accounts.filter(a =>
|
||||
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||
(a.username && a.username.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return result.sort((a, b) => {
|
||||
if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶
|
||||
if (b.username === 'gh_3dfda90e39d6') return 1;
|
||||
return b.last_time - a.last_time;
|
||||
});
|
||||
}, [accounts, searchKeyword]);
|
||||
|
||||
|
||||
if (loading) return <div className="biz-loading">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="biz-account-list">
|
||||
{filtered.map(item => (
|
||||
<div
|
||||
key={item.username}
|
||||
onClick={() => onSelect(item)}
|
||||
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={item.avatar}
|
||||
className="biz-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<div className="biz-info">
|
||||
<div className="biz-info-top">
|
||||
<span className="biz-name">{item.name || item.username}</span>
|
||||
<span className="biz-time">{item.formatted_last_time}</span>
|
||||
</div>
|
||||
{/*{item.username === 'gh_3dfda90e39d6' && (*/}
|
||||
{/* <div className="biz-badge type-service">微信支付</div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
<div className={`biz-badge ${
|
||||
item.type === '1' ? 'type-service' :
|
||||
item.type === '0' ? 'type-sub' :
|
||||
item.type === '2' ? 'type-enterprise' :
|
||||
item.type === '3' ? 'type-enterprise' : 'type-unknown'
|
||||
}`}>
|
||||
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BizMessageArea: React.FC<{
|
||||
account: BizAccount | null;
|
||||
}> = ({ account }) => {
|
||||
const themeMode = useThemeStore((state) => state.themeMode);
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const limit = 20;
|
||||
const messageListRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
const [myWxid, setMyWxid] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const initWxid = async () => {
|
||||
try {
|
||||
const wxid = await window.electronAPI.config.get('myWxid');
|
||||
if (wxid) {
|
||||
setMyWxid(wxid as string);
|
||||
}
|
||||
} catch (e) { }
|
||||
};
|
||||
initWxid();
|
||||
}, []);
|
||||
|
||||
const isDark = useMemo(() => {
|
||||
if (themeMode === 'dark') return true;
|
||||
if (themeMode === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return false;
|
||||
}, [themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account && myWxid) {
|
||||
setMessages([]);
|
||||
setOffset(0);
|
||||
setHasMore(true);
|
||||
isInitialLoadRef.current = true;
|
||||
loadMessages(account.username, 0);
|
||||
}
|
||||
}, [account, myWxid]);
|
||||
|
||||
const loadMessages = async (username: string, currentOffset: number) => {
|
||||
if (loading || !myWxid) return;
|
||||
|
||||
setLoading(true);
|
||||
if (messageListRef.current) {
|
||||
lastScrollHeightRef.current = messageListRef.current.scrollHeight;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (username === 'gh_3dfda90e39d6') {
|
||||
res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset);
|
||||
} else {
|
||||
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
|
||||
}
|
||||
|
||||
if (res) {
|
||||
if (res.length < limit) setHasMore(false);
|
||||
|
||||
setMessages(prev => {
|
||||
const combined = currentOffset === 0 ? res : [...res, ...prev];
|
||||
const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values());
|
||||
return uniqueMessages.sort((a, b) => a.create_time - b.create_time);
|
||||
});
|
||||
setOffset(currentOffset + limit);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载消息失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageListRef.current) return;
|
||||
|
||||
if (isInitialLoadRef.current && messages.length > 0) {
|
||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
|
||||
isInitialLoadRef.current = false;
|
||||
} else if (messages.length > 0 && !isInitialLoadRef.current && !loading) {
|
||||
|
||||
const newScrollHeight = messageListRef.current.scrollHeight;
|
||||
const heightDiff = newScrollHeight - lastScrollHeightRef.current;
|
||||
if (heightDiff > 0 && messageListRef.current.scrollTop < 100) {
|
||||
messageListRef.current.scrollTop += heightDiff;
|
||||
}
|
||||
}
|
||||
}, [messages, loading]);
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
// 向上滚动到顶部附近触发加载更多(更旧的消息)
|
||||
if (target.scrollTop < 50) {
|
||||
if (!loading && hasMore && account) {
|
||||
loadMessages(account.username, offset);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="biz-empty-state">
|
||||
<div className="empty-icon"><Newspaper size={40} /></div>
|
||||
<p>请选择一个服务号查看消息</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatMessageTime = (timestamp: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const isThisYear = date.getFullYear() === now.getFullYear();
|
||||
if (isThisYear) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
};
|
||||
|
||||
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
|
||||
|
||||
return (
|
||||
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
|
||||
<div className="main-header">
|
||||
<h2>{account.name}</h2>
|
||||
</div>
|
||||
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
|
||||
<div className="messages-wrapper">
|
||||
{hasMore && messages.length > 0 && (
|
||||
<div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
<div className="biz-no-record-container">
|
||||
<div className="no-record-icon">
|
||||
<MessageSquareOff size={48} />
|
||||
</div>
|
||||
<h3>暂无本地记录</h3>
|
||||
<p>该公众号在当前数据库中没有可显示的聊天历史</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, index) => {
|
||||
const showTime = true;
|
||||
|
||||
return (
|
||||
<div key={msg.local_id || index}>
|
||||
{showTime && (
|
||||
<div className="time-divider">
|
||||
<span>{formatMessageTime(msg.create_time)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{account.username === 'gh_3dfda90e39d6' ? (
|
||||
<div className="pay-card">
|
||||
<div className="pay-header">
|
||||
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
|
||||
<span>{msg.merchant_name || '微信支付'}</span>
|
||||
</div>
|
||||
<div className="pay-title">{msg.title}</div>
|
||||
<div className="pay-desc">{msg.description}</div>
|
||||
{/* <div className="pay-footer">{msg.formatted_time}</div> */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="article-card">
|
||||
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
|
||||
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
|
||||
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
|
||||
</div>
|
||||
{msg.des && <div className="article-digest">{msg.des}</div>}
|
||||
{msg.content_list && msg.content_list.length > 1 && (
|
||||
<div className="sub-articles">
|
||||
{msg.content_list.slice(1).map((item: any, idx: number) => (
|
||||
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
|
||||
<span className="sub-title">{item.title}</span>
|
||||
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loading && offset === 0 && <div className="biz-loading-more">加载中...</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BizPage: React.FC = () => {
|
||||
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
|
||||
return (
|
||||
<div className="biz-page">
|
||||
<div className="biz-sidebar">
|
||||
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
|
||||
</div>
|
||||
<BizMessageArea account={selectedAccount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BizPage;
|
||||
+256
-40
@@ -2127,6 +2127,24 @@
|
||||
display: block;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
-webkit-app-region: no-drag;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.image-message.pending {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image-message.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-stage {
|
||||
display: inline-block;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.image-stage.locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-message-wrapper {
|
||||
@@ -2694,43 +2712,76 @@
|
||||
|
||||
// 会话详情面板
|
||||
.detail-panel {
|
||||
width: 280px;
|
||||
width: clamp(280px, 25vw, 360px);
|
||||
min-width: 280px;
|
||||
background: var(--card-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
max-width: 360px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--card-bg) 94%, #fff 6%) 0%,
|
||||
var(--card-bg) 100%
|
||||
);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
box-shadow: -14px 0 28px rgba(0, 0, 0, 0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideInRight 0.2s ease;
|
||||
animation: slideInRight 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform, opacity;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 12px;
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, #fff 8%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
.detail-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-title-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2762,69 +2813,135 @@
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 68%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-overview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||
animation: detailCardEnter 0.24s ease both;
|
||||
|
||||
.detail-overview-avatar {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.detail-overview-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detail-overview-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail-overview-sub {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 86%, transparent);
|
||||
animation: detailCardEnter 0.24s ease both;
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
color: var(--primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-stats-meta {
|
||||
margin-top: -6px;
|
||||
margin-top: -2px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section:nth-child(2) {
|
||||
animation-delay: 0.03s;
|
||||
}
|
||||
|
||||
.detail-section:nth-child(3) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
|
||||
.detail-section:nth-child(4) {
|
||||
animation-delay: 0.09s;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
> svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
width: 88px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.value {
|
||||
@@ -2833,22 +2950,27 @@
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
line-height: 1.35;
|
||||
|
||||
&.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
font-size: 21px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-inline-btn {
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||
color: var(--primary);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -2856,6 +2978,7 @@
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
@@ -2868,12 +2991,12 @@
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
@@ -2889,18 +3012,27 @@
|
||||
&:hover .copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-within .copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-basic-section .label {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-table-placeholder {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -2910,18 +3042,64 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary) 26%, var(--border-color));
|
||||
}
|
||||
|
||||
.db-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
max-width: 62%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-count {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-detail-panel {
|
||||
.detail-content {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.detail-overview-card {
|
||||
gap: 10px;
|
||||
|
||||
.detail-overview-meta {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-overview-close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3122,6 +3300,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes detailCardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 语音转文字按钮样式 */
|
||||
.voice-transcribe-btn {
|
||||
width: 28px;
|
||||
@@ -4487,6 +4677,32 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 公众号入口样式
|
||||
.session-item.biz-entry {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
.biz-entry-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
// 消息信息弹窗
|
||||
.message-info-overlay {
|
||||
position: fixed;
|
||||
|
||||
+393
-146
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
@@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||
import * as configService from '../services/config'
|
||||
import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
@@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [
|
||||
266287972401, // 拍一拍
|
||||
]
|
||||
|
||||
const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual'
|
||||
|
||||
interface PendingInSessionSearchPayload {
|
||||
sessionId: string
|
||||
keyword: string
|
||||
@@ -61,6 +64,24 @@ const GLOBAL_MSG_LEGACY_CONCURRENCY = 6
|
||||
const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
||||
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
|
||||
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
|
||||
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
||||
|
||||
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
|
||||
|
||||
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
|
||||
const requestIdleCallbackFn = (
|
||||
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
||||
).requestIdleCallback
|
||||
|
||||
if (typeof requestIdleCallbackFn === 'function') {
|
||||
requestIdleCallbackFn(task, options?.timeout !== undefined ? { timeout: options.timeout } : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(task, options?.fallbackDelay ?? 0)
|
||||
}
|
||||
|
||||
function isGlobalMsgSearchCanceled(error: unknown): boolean {
|
||||
return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR
|
||||
@@ -207,6 +228,12 @@ function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'lo
|
||||
})
|
||||
}
|
||||
|
||||
function isRenderableImageSrc(value?: string | null): boolean {
|
||||
const src = String(value || '').trim()
|
||||
if (!src) return false
|
||||
return /^(https?:\/\/|data:image\/|blob:|file:\/\/|\/)/i.test(src)
|
||||
}
|
||||
|
||||
function normalizeSearchIdentityText(value?: string | null): string | undefined {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
@@ -983,6 +1010,7 @@ const SessionItem = React.memo(function SessionItem({
|
||||
)
|
||||
|
||||
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
||||
const isBizEntry = session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID
|
||||
|
||||
// 折叠入口:专属名称和图标
|
||||
if (isFoldEntry) {
|
||||
@@ -1007,6 +1035,29 @@ const SessionItem = React.memo(function SessionItem({
|
||||
)
|
||||
}
|
||||
|
||||
// 公众号入口:专属名称和图标
|
||||
if (isBizEntry) {
|
||||
return (
|
||||
<div
|
||||
className={`session-item biz-entry ${isActive ? 'active' : ''}`}
|
||||
onClick={() => onSelect(session)}
|
||||
>
|
||||
<div className="biz-entry-avatar">
|
||||
<Newspaper size={22} />
|
||||
</div>
|
||||
<div className="session-info">
|
||||
<div className="session-top">
|
||||
<span className="session-name">订阅号/服务号</span>
|
||||
<span className="session-time">{timeText}</span>
|
||||
</div>
|
||||
<div className="session-bottom">
|
||||
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 根据匹配字段显示不同的 summary
|
||||
const summaryContent = useMemo(() => {
|
||||
if (session.matchedField === 'wxid') {
|
||||
@@ -1152,7 +1203,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
|
||||
const topRangeLoadLockRef = useRef(false)
|
||||
const bottomRangeLoadLockRef = useRef(false)
|
||||
const topRangeLoadLastTriggerAtRef = useRef(0)
|
||||
const suppressAutoLoadLaterRef = useRef(false)
|
||||
const suppressAutoScrollOnNextMessageGrowthRef = useRef(false)
|
||||
const prependingHistoryRef = useRef(false)
|
||||
const isMessageListScrollingRef = useRef(false)
|
||||
const messageListScrollTimeoutRef = useRef<number | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
@@ -1204,6 +1260,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
||||
const [bizView, setBizView] = useState(false) // 是否在"公众号"视图
|
||||
const [selectedBizAccount, setSelectedBizAccount] = useState<BizAccount | null>(null)
|
||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||
@@ -1371,6 +1429,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, delayMs)
|
||||
}, [])
|
||||
|
||||
const markMessageListScrolling = useCallback(() => {
|
||||
isMessageListScrollingRef.current = true
|
||||
if (messageListScrollTimeoutRef.current !== null) {
|
||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}
|
||||
messageListScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isMessageListScrollingRef.current = false
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}, MESSAGE_LIST_SCROLL_IDLE_MS)
|
||||
}, [])
|
||||
|
||||
const isGroupChatSession = useCallback((username: string) => {
|
||||
return username.includes('@chatroom')
|
||||
}, [])
|
||||
@@ -2691,6 +2761,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
setHasMoreMessages(true)
|
||||
setFoldedView(false)
|
||||
setBizView(false)
|
||||
setSelectedBizAccount(null)
|
||||
setHasMoreLater(false)
|
||||
const scope = await resolveChatCacheScope()
|
||||
hydrateSessionListCache(scope)
|
||||
@@ -2901,15 +2974,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
await loadContactInfoBatch(usernames)
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => {
|
||||
void loadContactInfoBatch(usernames).finally(resolve)
|
||||
}, { timeout: 700 })
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
void loadContactInfoBatch(usernames).finally(resolve)
|
||||
}, 80)
|
||||
}
|
||||
scheduleWhenIdle(() => {
|
||||
void loadContactInfoBatch(usernames).finally(resolve)
|
||||
}, { timeout: 700, fallbackDelay: 80 })
|
||||
})
|
||||
}
|
||||
processedBatchCount += 1
|
||||
@@ -3008,7 +3075,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const loadContactInfoBatch = async (usernames: string[]) => {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate)
|
||||
// 在数据服务调用前让出控制权(使用 setTimeout 0 代替 setImmediate)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
const dllStart = performance.now()
|
||||
@@ -3019,7 +3086,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
const dllTime = performance.now() - dllStart
|
||||
|
||||
// DLL 调用后再次让出控制权
|
||||
//数据服务调用后再次让出控制权
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
const totalTime = performance.now() - startTime
|
||||
@@ -3201,19 +3268,32 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
|
||||
if (defer) {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => {
|
||||
runWarmup()
|
||||
}, { timeout: 1200 })
|
||||
} else {
|
||||
globalThis.setTimeout(runWarmup, 120)
|
||||
}
|
||||
scheduleWhenIdle(runWarmup, { timeout: 1200, fallbackDelay: 120 })
|
||||
return
|
||||
}
|
||||
|
||||
runWarmup()
|
||||
}, [loadContactInfoBatch])
|
||||
|
||||
const scheduleGroupSenderWarmup = useCallback((usernames: string[], defer = false) => {
|
||||
if (!Array.isArray(usernames) || usernames.length === 0) return
|
||||
const run = () => warmupGroupSenderProfiles(usernames, false)
|
||||
if (!defer && !isMessageListScrollingRef.current) {
|
||||
run()
|
||||
return
|
||||
}
|
||||
|
||||
const runWhenIdle = () => {
|
||||
if (isMessageListScrollingRef.current) {
|
||||
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
scheduleWhenIdle(runWhenIdle, { timeout: 1200, fallbackDelay: MESSAGE_LIST_SCROLL_IDLE_MS })
|
||||
}, [warmupGroupSenderProfiles])
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (
|
||||
sessionId: string,
|
||||
@@ -3223,6 +3303,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
ascending = false,
|
||||
options: LoadMessagesOptions = {}
|
||||
) => {
|
||||
const isPrependHistoryLoad = offset > 0 && !ascending
|
||||
if (isPrependHistoryLoad) {
|
||||
prependingHistoryRef.current = true
|
||||
}
|
||||
const listEl = messageListRef.current
|
||||
const session = sessionMapRef.current.get(sessionId)
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
@@ -3256,10 +3340,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
Math.max(visibleRange.startIndex, 0),
|
||||
Math.max(messages.length - 1, 0)
|
||||
)
|
||||
const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0
|
||||
? getMessageKey(messages[visibleStartIndex])
|
||||
: null
|
||||
|
||||
// 记录加载前的第一条消息元素(非虚拟列表回退路径)
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
@@ -3308,12 +3388,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true)
|
||||
scheduleGroupSenderWarmup(unknownSenders, options.deferGroupSenderWarmup === true)
|
||||
}
|
||||
}
|
||||
|
||||
// 日期跳转时滚动到顶部,否则滚动到底部
|
||||
const loadedMessages = result.messages
|
||||
requestAnimationFrame(() => {
|
||||
if (isDateJumpRef.current) {
|
||||
if (messageVirtuosoRef.current && resultMessages.length > 0) {
|
||||
@@ -3333,6 +3412,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const existingMessageKeys = messageKeySetRef.current
|
||||
const incomingSeen = new Set<string>()
|
||||
let prependedInsertedCount = 0
|
||||
for (const row of resultMessages) {
|
||||
const key = getMessageKey(row)
|
||||
if (incomingSeen.has(key)) continue
|
||||
incomingSeen.add(key)
|
||||
if (!existingMessageKeys.has(key)) {
|
||||
prependedInsertedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
suppressAutoScrollOnNextMessageGrowthRef.current = true
|
||||
appendMessages(resultMessages, true)
|
||||
|
||||
// 加载更多也同样处理发送者信息预取
|
||||
@@ -3343,24 +3435,20 @@ function ChatPage(props: ChatPageProps) {
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
warmupGroupSenderProfiles(unknownSenders, false)
|
||||
scheduleGroupSenderWarmup(unknownSenders, false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更早消息后保持视口锚点,避免跳屏
|
||||
const appendedMessages = result.messages
|
||||
requestAnimationFrame(() => {
|
||||
if (messageVirtuosoRef.current) {
|
||||
if (anchorMessageKeyBeforePrepend) {
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend)
|
||||
if (anchorIndex >= 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (resultMessages.length > 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' })
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const anchorIndex = Math.min(
|
||||
Math.max(visibleStartIndex + prependedInsertedCount, 0),
|
||||
Math.max(latestMessages.length - 1, 0)
|
||||
)
|
||||
if (latestMessages.length > 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -3400,6 +3488,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
setMessages([])
|
||||
}
|
||||
} finally {
|
||||
if (isPrependHistoryLoad) {
|
||||
requestAnimationFrame(() => {
|
||||
prependingHistoryRef.current = false
|
||||
})
|
||||
}
|
||||
setLoadingMessages(false)
|
||||
setLoadingMore(false)
|
||||
if (offset === 0 && pendingSessionLoadRef.current === sessionId) {
|
||||
@@ -3430,9 +3523,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(end)
|
||||
suppressAutoLoadLaterRef.current = true
|
||||
setShowJumpPopover(false)
|
||||
void loadMessages(targetSessionId, 0, 0, end, false, {
|
||||
switchRequestSeq: options.switchRequestSeq
|
||||
switchRequestSeq: options.switchRequestSeq,
|
||||
forceInitialLimit: 120
|
||||
})
|
||||
}, [currentSessionId, loadMessages])
|
||||
|
||||
@@ -3964,6 +4059,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
setFoldedView(true)
|
||||
return
|
||||
}
|
||||
// 点击公众号入口,切换到公众号视图
|
||||
if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) {
|
||||
setBizView(true)
|
||||
setSelectedBizAccount(null) // 切入时默认不选中任何公众号
|
||||
return
|
||||
}
|
||||
selectSessionById(session.username)
|
||||
}
|
||||
|
||||
@@ -4342,36 +4443,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (range.endIndex >= Math.max(total - 2, 0)) {
|
||||
isMessageListAtBottomRef.current = true
|
||||
setShowScrollToBottom(prev => (prev ? false : prev))
|
||||
}
|
||||
|
||||
if (
|
||||
range.startIndex <= 2 &&
|
||||
!topRangeLoadLockRef.current &&
|
||||
!isLoadingMore &&
|
||||
!isLoadingMessages &&
|
||||
hasMoreMessages &&
|
||||
currentSessionId
|
||||
) {
|
||||
topRangeLoadLockRef.current = true
|
||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
}
|
||||
|
||||
if (
|
||||
range.endIndex >= total - 3 &&
|
||||
!bottomRangeLoadLockRef.current &&
|
||||
!suppressAutoLoadLaterRef.current &&
|
||||
!isLoadingMore &&
|
||||
!isLoadingMessages &&
|
||||
hasMoreLater &&
|
||||
currentSessionId
|
||||
) {
|
||||
bottomRangeLoadLockRef.current = true
|
||||
void loadLaterMessages()
|
||||
}
|
||||
|
||||
if (shouldWarmupVisibleGroupSenders) {
|
||||
const now = Date.now()
|
||||
if (now - lastVisibleSenderWarmupAtRef.current >= 180) {
|
||||
@@ -4390,27 +4461,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (pendingUsernames.size >= 24) break
|
||||
}
|
||||
if (pendingUsernames.size > 0) {
|
||||
warmupGroupSenderProfiles([...pendingUsernames], false)
|
||||
scheduleGroupSenderWarmup([...pendingUsernames], false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messages.length,
|
||||
isLoadingMore,
|
||||
isLoadingMessages,
|
||||
hasMoreMessages,
|
||||
hasMoreLater,
|
||||
currentSessionId,
|
||||
currentOffset,
|
||||
jumpStartTime,
|
||||
jumpEndTime,
|
||||
isGroupChatSession,
|
||||
standaloneSessionWindow,
|
||||
normalizedInitialSessionId,
|
||||
normalizedStandaloneInitialContactType,
|
||||
warmupGroupSenderProfiles,
|
||||
loadMessages,
|
||||
loadLaterMessages
|
||||
scheduleGroupSenderWarmup
|
||||
])
|
||||
|
||||
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
|
||||
@@ -4424,9 +4486,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
const distanceFromBottom = listEl
|
||||
? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight))
|
||||
: Number.POSITIVE_INFINITY
|
||||
const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0)
|
||||
const nearBottomByDistance = distanceFromBottom <= 140
|
||||
const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance
|
||||
const effectiveAtBottom = atBottom || nearBottomByDistance
|
||||
isMessageListAtBottomRef.current = effectiveAtBottom
|
||||
|
||||
if (!effectiveAtBottom) {
|
||||
@@ -4454,19 +4515,48 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
||||
|
||||
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY <= 18) return
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return
|
||||
markMessageListScrolling()
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages) return
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
const distanceFromTop = listEl.scrollTop
|
||||
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
||||
if (distanceFromBottom > 96) return
|
||||
|
||||
if (event.deltaY <= -18) {
|
||||
if (!hasMoreMessages) return
|
||||
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
||||
if (topRangeLoadLockRef.current) return
|
||||
const now = Date.now()
|
||||
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return
|
||||
topRangeLoadLastTriggerAtRef.current = now
|
||||
topRangeLoadLockRef.current = true
|
||||
isMessageListAtBottomRef.current = false
|
||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.deltaY <= 18) return
|
||||
if (!hasMoreLater) return
|
||||
if (distanceFromBottom > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
||||
if (bottomRangeLoadLockRef.current) return
|
||||
|
||||
// 用户明确向下滚动时允许加载后续消息
|
||||
suppressAutoLoadLaterRef.current = false
|
||||
bottomRangeLoadLockRef.current = true
|
||||
void loadLaterMessages()
|
||||
}, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages])
|
||||
}, [
|
||||
currentSessionId,
|
||||
hasMoreLater,
|
||||
hasMoreMessages,
|
||||
isLoadingMessages,
|
||||
isLoadingMore,
|
||||
currentOffset,
|
||||
jumpStartTime,
|
||||
jumpEndTime,
|
||||
markMessageListScrolling,
|
||||
loadMessages,
|
||||
loadLaterMessages
|
||||
])
|
||||
|
||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||
if (!atTop) {
|
||||
@@ -4621,6 +4711,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
if (messageListScrollTimeoutRef.current !== null) {
|
||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}
|
||||
isMessageListScrollingRef.current = false
|
||||
contactUpdateQueueRef.current.clear()
|
||||
pendingSessionContactEnrichRef.current.clear()
|
||||
sessionContactEnrichAttemptAtRef.current.clear()
|
||||
@@ -4661,8 +4756,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
lastObservedMessageCountRef.current = currentCount
|
||||
if (currentCount <= previousCount) return
|
||||
if (!currentSessionId || isLoadingMessages || isSessionSwitching) return
|
||||
const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0)
|
||||
if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return
|
||||
if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) {
|
||||
suppressAutoScrollOnNextMessageGrowthRef.current = false
|
||||
return
|
||||
}
|
||||
if (!isMessageListAtBottomRef.current) return
|
||||
if (suppressAutoLoadLaterRef.current) return
|
||||
suppressScrollToBottomButton(220)
|
||||
isMessageListAtBottomRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
@@ -4946,14 +5045,30 @@ function ChatPage(props: ChatPageProps) {
|
||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||
const hasFoldedGroups = foldedGroups.length > 0
|
||||
|
||||
const visible = sessions.filter(s => {
|
||||
let visible = sessions.filter(s => {
|
||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 如果有折叠的群聊,但列表中没有入口,则插入入口
|
||||
const bizEntry: ChatSession = {
|
||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||
displayName: '公众号',
|
||||
summary: '查看公众号历史消息',
|
||||
type: 0,
|
||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
unreadCount: 0,
|
||||
isMuted: false,
|
||||
isFolded: false
|
||||
}
|
||||
|
||||
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||
visible.unshift(bizEntry)
|
||||
}
|
||||
|
||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||
// 找到最新的折叠消息
|
||||
|
||||
const latestFolded = foldedGroups.reduce((latest, current) => {
|
||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||
@@ -6031,7 +6146,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
ref={sidebarRef}
|
||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||
>
|
||||
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
|
||||
<div className={`session-header session-header-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||
{/* 普通 header */}
|
||||
<div className="session-header-panel main-header">
|
||||
<div className="search-row">
|
||||
@@ -6061,12 +6176,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
{/* 折叠群 header */}
|
||||
<div className="session-header-panel folded-header">
|
||||
<div className="folded-view-header">
|
||||
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
|
||||
<button className="icon-btn back-btn" onClick={() => {
|
||||
setFoldedView(false)
|
||||
setBizView(false)
|
||||
}}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="folded-view-title">
|
||||
<Users size={14} />
|
||||
折叠的群聊
|
||||
{foldedView ? (
|
||||
<><Users size={14} /> 折叠的群聊</>
|
||||
) : bizView ? (
|
||||
<><Newspaper size={14} /> 订阅号/服务号</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6173,7 +6294,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
|
||||
<div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||
{/* 普通会话列表 */}
|
||||
<div className="session-list-panel main-panel">
|
||||
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||
@@ -6199,7 +6320,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
<SessionItem
|
||||
key={session.username}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.username}
|
||||
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||
onSelect={handleSelectSession}
|
||||
formatTime={formatSessionTime}
|
||||
searchKeyword={searchKeyword}
|
||||
@@ -6218,24 +6339,36 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
{/* 折叠群列表 */}
|
||||
<div className="session-list-panel folded-panel">
|
||||
{foldedSessions.length > 0 ? (
|
||||
<div className="session-list">
|
||||
{foldedSessions.map(session => (
|
||||
<SessionItem
|
||||
key={session.username}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.username}
|
||||
onSelect={handleSelectSession}
|
||||
formatTime={formatSessionTime}
|
||||
searchKeyword={searchKeyword}
|
||||
{foldedView && (
|
||||
foldedSessions.length > 0 ? (
|
||||
<div className="session-list">
|
||||
{foldedSessions.map(session => (
|
||||
<SessionItem
|
||||
key={session.username}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||
onSelect={handleSelectSession}
|
||||
formatTime={formatSessionTime}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-sessions">
|
||||
<Users size={32} />
|
||||
<p>没有折叠的群聊</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{bizView && (
|
||||
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||
<BizAccountList
|
||||
onSelect={setSelectedBizAccount}
|
||||
selectedUsername={selectedBizAccount?.username}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-sessions">
|
||||
<Users size={32} />
|
||||
<p>没有折叠的群聊</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6247,9 +6380,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
{/* 右侧消息区域 */}
|
||||
<div className="message-area">
|
||||
{currentSession ? (
|
||||
<>
|
||||
<div className="message-header">
|
||||
{bizView ? (
|
||||
<BizMessageArea account={selectedBizAccount} />
|
||||
) : currentSession ? (
|
||||
<>
|
||||
<div className="message-header">
|
||||
<Avatar
|
||||
src={currentSession.avatarUrl}
|
||||
name={currentSession.displayName || currentSession.username}
|
||||
@@ -6529,6 +6664,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
<div
|
||||
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
||||
ref={handleMessageListScrollParentRef}
|
||||
onScroll={markMessageListScrolling}
|
||||
onWheel={handleMessageListWheel}
|
||||
>
|
||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
|
||||
@@ -6542,8 +6678,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
className="message-virtuoso"
|
||||
customScrollParent={messageListScrollParent ?? undefined}
|
||||
data={messages}
|
||||
overscan={360}
|
||||
followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)}
|
||||
overscan={220}
|
||||
followOutput={(atBottom) => (
|
||||
prependingHistoryRef.current
|
||||
? false
|
||||
: (atBottom && isMessageListAtBottomRef.current ? 'auto' : false)
|
||||
)}
|
||||
atBottomThreshold={80}
|
||||
atBottomStateChange={handleMessageAtBottomStateChange}
|
||||
atTopStateChange={handleMessageAtTopStateChange}
|
||||
@@ -6655,13 +6795,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
{/* 会话详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<div className="detail-panel">
|
||||
<div className="detail-header">
|
||||
<h4>会话详情</h4>
|
||||
<button className="close-btn" onClick={() => setShowDetailPanel(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-panel session-detail-panel">
|
||||
{isLoadingDetail && !sessionDetail ? (
|
||||
<div className="detail-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
@@ -6669,7 +6803,27 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
) : sessionDetail ? (
|
||||
<div className="detail-content">
|
||||
<div className="detail-section">
|
||||
<div className="detail-overview-card">
|
||||
<Avatar
|
||||
src={currentSession?.avatarUrl}
|
||||
name={sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.wxid}
|
||||
size={42}
|
||||
className="detail-overview-avatar"
|
||||
/>
|
||||
<div className="detail-overview-meta">
|
||||
<span className="detail-overview-name">
|
||||
{sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.alias || sessionDetail.wxid}
|
||||
</span>
|
||||
<span className="detail-overview-sub">
|
||||
{sessionDetail.alias || sessionDetail.wxid}
|
||||
</span>
|
||||
</div>
|
||||
<button className="detail-overview-close-btn" onClick={() => setShowDetailPanel(false)} title="关闭详情">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="detail-section detail-basic-section">
|
||||
<div className="detail-item">
|
||||
<Hash size={14} />
|
||||
<span className="label">微信ID</span>
|
||||
@@ -6707,10 +6861,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="detail-section detail-stats-section">
|
||||
<div className="section-title">
|
||||
<MessageSquare size={14} />
|
||||
<span>消息统计(导出口径)</span>
|
||||
<span>消息统计</span>
|
||||
</div>
|
||||
<div className="detail-stats-meta">
|
||||
{isRefreshingDetailStats
|
||||
@@ -6868,7 +7022,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="detail-section detail-db-section">
|
||||
<div className="section-title">
|
||||
<Database size={14} />
|
||||
<span>数据库分布</span>
|
||||
@@ -7585,6 +7739,8 @@ function MessageBubble({
|
||||
// State variables...
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
|
||||
const [imageHasUpdate, setImageHasUpdate] = useState(false)
|
||||
const [imageClicked, setImageClicked] = useState(false)
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
@@ -7630,6 +7786,11 @@ function MessageBubble({
|
||||
const videoContainerRef = useRef<HTMLElement>(null)
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||
const [videoMd5, setVideoMd5] = useState<string | null>(null)
|
||||
const imageStageLockStyle = useMemo<React.CSSProperties | undefined>(() => (
|
||||
imageStageLockHeight && imageStageLockHeight > 0
|
||||
? { height: `${Math.round(imageStageLockHeight)}px` }
|
||||
: undefined
|
||||
), [imageStageLockHeight])
|
||||
|
||||
// 解析视频 MD5
|
||||
useEffect(() => {
|
||||
@@ -7773,6 +7934,14 @@ function MessageBubble({
|
||||
captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef)
|
||||
}, [captureResizeBaseline])
|
||||
|
||||
const lockImageStageHeight = useCallback(() => {
|
||||
const host = imageContainerRef.current
|
||||
if (!host) return
|
||||
const height = host.getBoundingClientRect().height
|
||||
if (!Number.isFinite(height) || height <= 0) return
|
||||
setImageStageLockHeight(Math.round(height))
|
||||
}, [])
|
||||
|
||||
const captureEmojiResizeBaseline = useCallback(() => {
|
||||
captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef)
|
||||
}, [captureResizeBaseline])
|
||||
@@ -7781,6 +7950,12 @@ function MessageBubble({
|
||||
stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef)
|
||||
}, [stabilizeScrollAfterResize])
|
||||
|
||||
const releaseImageStageLock = useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setImageStageLockHeight(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stabilizeEmojiScrollAfterResize = useCallback(() => {
|
||||
stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef)
|
||||
}, [stabilizeScrollAfterResize])
|
||||
@@ -7934,6 +8109,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (imageLocalPath !== result.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(false)
|
||||
@@ -7949,6 +8125,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||
if (imageLocalPath !== dataUrl) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(dataUrl)
|
||||
setImageHasUpdate(false)
|
||||
@@ -7962,7 +8139,7 @@ function MessageBubble({
|
||||
imageDecryptPendingRef.current = false
|
||||
}
|
||||
return { success: false }
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline])
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8025,6 +8202,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
if (imageLocalPath !== resolved.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(resolved.localPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
@@ -8039,6 +8217,7 @@ function MessageBubble({
|
||||
imageLocalPath,
|
||||
imageCacheKey,
|
||||
captureImageResizeBaseline,
|
||||
lockImageStageHeight,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
requestImageDecrypt,
|
||||
@@ -8053,6 +8232,16 @@ function MessageBubble({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
}, [imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageLoading) return
|
||||
if (!imageError && imageLocalPath) return
|
||||
setImageStageLockHeight(null)
|
||||
}, [imageError, imageLoading, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || imageLoading) return
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8069,6 +8258,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageError(false)
|
||||
}
|
||||
@@ -8079,7 +8269,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline])
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
@@ -8113,6 +8303,7 @@ function MessageBubble({
|
||||
}
|
||||
if (imageLocalPath !== payload.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
|
||||
setImageError(false)
|
||||
@@ -8121,7 +8312,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline])
|
||||
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
// 图片进入视野前自动解密(懒加载)
|
||||
useEffect(() => {
|
||||
@@ -8504,11 +8695,46 @@ function MessageBubble({
|
||||
appMsgTextCache.set(selector, value)
|
||||
return value
|
||||
}, [appMsgDoc, appMsgTextCache])
|
||||
const queryPreferredQuotedContent = useCallback((): string => {
|
||||
if (message.quotedContent) return message.quotedContent
|
||||
const candidates = [
|
||||
'refermsg > selectedcontent',
|
||||
'refermsg > selectedtext',
|
||||
'refermsg > selectcontent',
|
||||
'refermsg > selecttext',
|
||||
'refermsg > quotecontent',
|
||||
'refermsg > quotetext',
|
||||
'refermsg > partcontent',
|
||||
'refermsg > parttext',
|
||||
'refermsg > excerpt',
|
||||
'refermsg > summary',
|
||||
'refermsg > preview',
|
||||
'refermsg > content'
|
||||
]
|
||||
for (const selector of candidates) {
|
||||
const value = queryAppMsgText(selector)
|
||||
if (value) return value
|
||||
}
|
||||
return ''
|
||||
}, [message.quotedContent, queryAppMsgText])
|
||||
const appMsgThumbRawCandidate = useMemo(() => (
|
||||
message.linkThumb ||
|
||||
message.appMsgThumbUrl ||
|
||||
queryAppMsgText('appmsg > thumburl') ||
|
||||
queryAppMsgText('appmsg > cdnthumburl') ||
|
||||
queryAppMsgText('appmsg > cover') ||
|
||||
queryAppMsgText('appmsg > coverurl') ||
|
||||
queryAppMsgText('thumburl') ||
|
||||
queryAppMsgText('cdnthumburl') ||
|
||||
queryAppMsgText('cover') ||
|
||||
queryAppMsgText('coverurl') ||
|
||||
''
|
||||
).trim(), [message.linkThumb, message.appMsgThumbUrl, queryAppMsgText])
|
||||
const quotedSenderUsername = resolveQuotedSenderUsername(
|
||||
queryAppMsgText('refermsg > fromusr'),
|
||||
queryAppMsgText('refermsg > chatusr')
|
||||
)
|
||||
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || ''
|
||||
const quotedContent = queryPreferredQuotedContent()
|
||||
const quotedSenderFallbackName = useMemo(
|
||||
() => resolveQuotedSenderFallbackDisplayName(
|
||||
session.username,
|
||||
@@ -8637,6 +8863,17 @@ function MessageBubble({
|
||||
// Selection mode handling removed from here to allow normal rendering
|
||||
// We will wrap the output instead
|
||||
if (isSystem) {
|
||||
const isPatSystemMessage = message.localType === 266287972401
|
||||
const patTitleRaw = isPatSystemMessage
|
||||
? (queryAppMsgText('appmsg > title') || queryAppMsgText('title') || message.parsedContent || '')
|
||||
: ''
|
||||
const patDisplayText = isPatSystemMessage
|
||||
? cleanMessageContent(String(patTitleRaw).replace(/^\s*\[拍一拍\]\s*/i, ''))
|
||||
: ''
|
||||
const systemContentNode = isPatSystemMessage
|
||||
? renderTextWithEmoji(patDisplayText || '拍一拍')
|
||||
: message.parsedContent
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`}
|
||||
@@ -8665,7 +8902,7 @@ function MessageBubble({
|
||||
{isSelected && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="bubble-content">{message.parsedContent}</div>
|
||||
<div className="bubble-content">{systemContentNode}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8674,7 +8911,11 @@ function MessageBubble({
|
||||
const renderContent = () => {
|
||||
if (isImage) {
|
||||
return (
|
||||
<div ref={imageContainerRef}>
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
|
||||
style={imageStageLockStyle}
|
||||
>
|
||||
{imageLoading ? (
|
||||
<div className="image-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
@@ -8696,15 +8937,19 @@ function MessageBubble({
|
||||
<img
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||
onClick={() => { void handleOpenImageViewer() }}
|
||||
onLoad={() => {
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
stabilizeImageScrollAfterResize()
|
||||
releaseImageStageLock()
|
||||
}}
|
||||
onError={() => {
|
||||
imageResizeBaselineRef.current = null
|
||||
setImageLoaded(false)
|
||||
setImageError(true)
|
||||
releaseImageStageLock()
|
||||
}}
|
||||
/>
|
||||
{imageLiveVideoPath && (
|
||||
@@ -9030,10 +9275,16 @@ function MessageBubble({
|
||||
|
||||
const xmlType = message.xmlType || q('appmsg > type') || q('type')
|
||||
|
||||
// type 62: 拍一拍(按普通文本渲染,支持 [烟花] 这类 emoji 占位符)
|
||||
if (xmlType === '62') {
|
||||
const patText = cleanMessageContent((q('title') || cleanedParsedContent || '').replace(/^\s*\[拍一拍\]\s*/i, ''))
|
||||
return <div className="bubble-content">{renderTextWithEmoji(patText || '拍一拍')}</div>
|
||||
}
|
||||
|
||||
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式
|
||||
if (xmlType === '57') {
|
||||
const replyText = q('title') || cleanedParsedContent || ''
|
||||
const referContent = q('refermsg > content') || ''
|
||||
const referContent = queryPreferredQuotedContent()
|
||||
const referType = q('refermsg > type') || ''
|
||||
|
||||
// 根据被引用消息类型渲染对应内容
|
||||
@@ -9073,7 +9324,8 @@ function MessageBubble({
|
||||
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
|
||||
const desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
|
||||
const fallbackThumbUrl = appMsgThumbRawCandidate
|
||||
const thumbUrl = isRenderableImageSrc(fallbackThumbUrl) ? fallbackThumbUrl : ''
|
||||
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
|
||||
const sourceName = message.appMsgSourceName || q('sourcename')
|
||||
const sourceDisplayName = q('sourcedisplayname') || ''
|
||||
@@ -9147,9 +9399,7 @@ function MessageBubble({
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -9157,7 +9407,7 @@ function MessageBubble({
|
||||
if (kind === 'quote') {
|
||||
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
||||
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
|
||||
const referContent = message.quotedContent || q('refermsg > content') || ''
|
||||
const referContent = queryPreferredQuotedContent()
|
||||
return (
|
||||
renderBubbleWithQuote(
|
||||
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
|
||||
@@ -9348,7 +9598,7 @@ function MessageBubble({
|
||||
// 引用回复消息 (type=57),防止被误判为链接
|
||||
if (appMsgType === '57') {
|
||||
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
|
||||
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
||||
const referContent = queryPreferredQuotedContent()
|
||||
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
||||
|
||||
const renderReferContent2 = () => {
|
||||
@@ -9589,9 +9839,6 @@ function MessageBubble({
|
||||
</div>
|
||||
<div className="link-body">
|
||||
<div className="link-desc" title={desc}>{desc}</div>
|
||||
<div className="link-thumb-placeholder">
|
||||
<Link size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
}
|
||||
|
||||
.scene-message.sent .scene-avatar {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
|
||||
border-color: rgba(var(--ar-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.dual-stat-grid {
|
||||
@@ -981,4 +981,4 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+486
-18
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import ReportHeatmap from '../components/ReportHeatmap'
|
||||
import ReportWordCloud from '../components/ReportWordCloud'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { drawPatternBackground } from '../utils/reportExport'
|
||||
import './AnnualReportWindow.scss'
|
||||
import './DualReportWindow.scss'
|
||||
|
||||
@@ -66,6 +70,12 @@ interface DualReportData {
|
||||
streak?: { days: number; startDate: string; endDate: string }
|
||||
}
|
||||
|
||||
interface SectionInfo {
|
||||
id: string
|
||||
name: string
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
function DualReportWindow() {
|
||||
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -75,6 +85,29 @@ function DualReportWindow() {
|
||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState('')
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||
|
||||
const { themeMode } = useThemeStore()
|
||||
|
||||
const sectionRefs = {
|
||||
cover: useRef<HTMLElement>(null),
|
||||
firstChat: useRef<HTMLElement>(null),
|
||||
yearFirstChat: useRef<HTMLElement>(null),
|
||||
heatmap: useRef<HTMLElement>(null),
|
||||
initiative: useRef<HTMLElement>(null),
|
||||
response: useRef<HTMLElement>(null),
|
||||
streak: useRef<HTMLElement>(null),
|
||||
wordCloud: useRef<HTMLElement>(null),
|
||||
stats: useRef<HTMLElement>(null),
|
||||
ending: useRef<HTMLElement>(null)
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
@@ -151,6 +184,351 @@ function DualReportWindow() {
|
||||
void loadEmojis()
|
||||
}, [reportData])
|
||||
|
||||
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
||||
|
||||
const sanitizeFileNameSegment = (value: string) => {
|
||||
const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim()
|
||||
return sanitized || '好友'
|
||||
}
|
||||
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
|
||||
const sections: SectionInfo[] = [
|
||||
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
|
||||
{ id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat }
|
||||
]
|
||||
|
||||
if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) {
|
||||
sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat })
|
||||
}
|
||||
if (reportData.heatmap) {
|
||||
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
|
||||
}
|
||||
if (reportData.initiative) {
|
||||
sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative })
|
||||
}
|
||||
if (reportData.response) {
|
||||
sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response })
|
||||
}
|
||||
if (reportData.streak) {
|
||||
sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak })
|
||||
}
|
||||
|
||||
sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud })
|
||||
sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats })
|
||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
|
||||
const element = section.ref.current
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
const OUTPUT_WIDTH = 1920
|
||||
const OUTPUT_HEIGHT = 1080
|
||||
let wordCloudInner: HTMLElement | null = null
|
||||
let wordTags: NodeListOf<HTMLElement> | null = null
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
const originalStyle = element.style.cssText
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
|
||||
element.style.minHeight = 'auto'
|
||||
element.style.padding = '40px 20px'
|
||||
element.style.background = 'transparent'
|
||||
element.style.backgroundColor = 'transparent'
|
||||
element.style.boxShadow = 'none'
|
||||
|
||||
wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null
|
||||
wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, index) => {
|
||||
wordTagOriginalStyles[index] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: 'transparent',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
}
|
||||
})
|
||||
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = OUTPUT_WIDTH
|
||||
outputCanvas.height = OUTPUT_HEIGHT
|
||||
const ctx = outputCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
|
||||
|
||||
const PADDING = 80
|
||||
const contentWidth = OUTPUT_WIDTH - PADDING * 2
|
||||
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
|
||||
const srcRatio = canvas.width / canvas.height
|
||||
const dstRatio = contentWidth / contentHeight
|
||||
|
||||
let drawWidth: number
|
||||
let drawHeight: number
|
||||
let drawX: number
|
||||
let drawY: number
|
||||
|
||||
if (srcRatio > dstRatio) {
|
||||
drawWidth = contentWidth
|
||||
drawHeight = contentWidth / srcRatio
|
||||
drawX = PADDING
|
||||
drawY = PADDING + (contentHeight - drawHeight) / 2
|
||||
} else {
|
||||
drawHeight = contentHeight
|
||||
drawWidth = contentHeight * srcRatio
|
||||
drawX = PADDING + (contentWidth - drawWidth) / 2
|
||||
drawY = PADDING
|
||||
}
|
||||
|
||||
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
|
||||
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
element.style.cssText = originalStyle
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
wordTags?.forEach((tag, index) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[index]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
}
|
||||
}
|
||||
|
||||
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||
if (!containerRef.current || !reportData) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setExportProgress('正在生成长图...')
|
||||
|
||||
let wordCloudInner: HTMLElement | null = null
|
||||
let wordTags: NodeListOf<HTMLElement> | null = null
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
const container = containerRef.current
|
||||
const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[]
|
||||
const originalStyles = sections.map((section) => section.style.cssText)
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
|
||||
sections.forEach((section) => {
|
||||
section.style.minHeight = 'auto'
|
||||
section.style.padding = '40px 0'
|
||||
})
|
||||
|
||||
if (filterIds) {
|
||||
getAvailableSections().forEach((section) => {
|
||||
if (!filterIds.has(section.id) && section.ref.current) {
|
||||
section.ref.current.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null
|
||||
wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, index) => {
|
||||
wordTagOriginalStyles[index] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
backgroundColor: 'transparent',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
}
|
||||
})
|
||||
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = canvas.width
|
||||
outputCanvas.height = canvas.height
|
||||
const ctx = outputCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建导出画布')
|
||||
}
|
||||
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
|
||||
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
||||
const link = document.createElement('a')
|
||||
link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = outputCanvas.toDataURL('image/png')
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (e) {
|
||||
alert('导出失败: ' + String(e))
|
||||
} finally {
|
||||
sections.forEach((section, index) => {
|
||||
section.style.cssText = originalStyles[index]
|
||||
})
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
wordTags?.forEach((tag, index) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[index]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
}
|
||||
}
|
||||
|
||||
const exportSelectedSections = async () => {
|
||||
if (!reportData) return
|
||||
|
||||
const sections = getAvailableSections().filter((section) => selectedSections.has(section.id))
|
||||
if (sections.length === 0) {
|
||||
alert('请至少选择一个板块')
|
||||
return
|
||||
}
|
||||
|
||||
if (exportMode === 'long') {
|
||||
setShowExportModal(false)
|
||||
await exportFullReport(selectedSections)
|
||||
setSelectedSections(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setShowExportModal(false)
|
||||
|
||||
const exportedImages: Array<{ name: string; data: string }> = []
|
||||
|
||||
for (let index = 0; index < sections.length; index++) {
|
||||
const section = sections[index]
|
||||
setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`)
|
||||
|
||||
const result = await exportSection(section)
|
||||
if (result) {
|
||||
exportedImages.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedImages.length === 0) {
|
||||
alert('导出失败')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
|
||||
const dirResult = await window.electronAPI.dialog.openDirectory({
|
||||
title: '选择导出文件夹',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`,
|
||||
images: exportedImages.map((image) => ({
|
||||
name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`,
|
||||
dataUrl: image.data
|
||||
}))
|
||||
})
|
||||
|
||||
if (!exportResult.success) {
|
||||
alert('导出失败: ' + (exportResult.error || '未知错误'))
|
||||
}
|
||||
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
setSelectedSections(new Set())
|
||||
}
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
const next = new Set(selectedSections)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
setSelectedSections(next)
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
const sections = getAvailableSections()
|
||||
if (selectedSections.size === sections.length) {
|
||||
setSelectedSections(new Set())
|
||||
return
|
||||
}
|
||||
setSelectedSections(new Set(sections.map((section) => section.id)))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-window loading">
|
||||
@@ -305,7 +683,7 @@ function DualReportWindow() {
|
||||
if (emojiUrl) {
|
||||
return (
|
||||
<div className="report-emoji-container">
|
||||
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
|
||||
<img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||
}} />
|
||||
@@ -356,7 +734,7 @@ function DualReportWindow() {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<div className="scene-avatar with-image">
|
||||
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
|
||||
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -419,9 +797,99 @@ function DualReportWindow() {
|
||||
<div className="deco-circle c5" />
|
||||
</div>
|
||||
|
||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||
<button
|
||||
className="fab-item"
|
||||
onClick={() => {
|
||||
setFabOpen(false)
|
||||
setExportMode('separate')
|
||||
setShowExportModal(true)
|
||||
}}
|
||||
title="分模块导出"
|
||||
>
|
||||
<Image size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="fab-item"
|
||||
onClick={() => {
|
||||
setFabOpen(false)
|
||||
setExportMode('long')
|
||||
setShowExportModal(true)
|
||||
}}
|
||||
title="自定义导出长图"
|
||||
>
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="fab-item"
|
||||
onClick={() => {
|
||||
setFabOpen(false)
|
||||
void exportFullReport()
|
||||
}}
|
||||
title="导出长图"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
|
||||
{fabOpen ? <X size={22} /> : <Download size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-progress-modal">
|
||||
<div className="export-spinner">
|
||||
<div className="spinner-ring"></div>
|
||||
<Download size={24} className="spinner-icon" />
|
||||
</div>
|
||||
<p className="export-title">正在导出</p>
|
||||
<p className="export-status">{exportProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showExportModal && (
|
||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-grid">
|
||||
{getAvailableSections().map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggleSection(section.id)}
|
||||
>
|
||||
<div className="card-check">
|
||||
{selectedSections.has(section.id) && <Check size={14} />}
|
||||
</div>
|
||||
<span>{section.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="select-all-btn" onClick={toggleAll}>
|
||||
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onClick={() => void exportSelectedSections()}
|
||||
disabled={selectedSections.size === 0}
|
||||
>
|
||||
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-scroll-view">
|
||||
<div className="report-container">
|
||||
<section className="section">
|
||||
<div className="report-container" ref={containerRef}>
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
||||
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
@@ -433,7 +901,7 @@ function DualReportWindow() {
|
||||
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.firstChat}>
|
||||
<div className="label-text">首次聊天</div>
|
||||
<h2 className="hero-title">故事的开始</h2>
|
||||
{firstChat ? (
|
||||
@@ -457,7 +925,7 @@ function DualReportWindow() {
|
||||
</section>
|
||||
|
||||
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.yearFirstChat}>
|
||||
<div className="label-text">第一段对话</div>
|
||||
<h2 className="hero-title">
|
||||
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
|
||||
@@ -473,7 +941,7 @@ function DualReportWindow() {
|
||||
) : null}
|
||||
|
||||
{reportData.heatmap && (
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.heatmap}>
|
||||
<div className="label-text">聊天习惯</div>
|
||||
<h2 className="hero-title">作息规律</h2>
|
||||
{mostActive && (
|
||||
@@ -486,14 +954,14 @@ function DualReportWindow() {
|
||||
)}
|
||||
|
||||
{reportData.initiative && (
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.initiative}>
|
||||
<div className="label-text">主动性</div>
|
||||
<h2 className="hero-title">情感的天平</h2>
|
||||
<div className="initiative-container">
|
||||
<div className="initiative-bar-wrapper">
|
||||
<div className="initiative-side">
|
||||
<div className="avatar-placeholder">
|
||||
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
|
||||
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
|
||||
</div>
|
||||
<div className="count">{reportData.initiative.initiated}次</div>
|
||||
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
|
||||
@@ -507,7 +975,7 @@ function DualReportWindow() {
|
||||
</div>
|
||||
<div className="initiative-side">
|
||||
<div className="avatar-placeholder">
|
||||
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
|
||||
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
|
||||
</div>
|
||||
<div className="count">{reportData.initiative.received}次</div>
|
||||
<div className="percent">{receivedPercent.toFixed(1)}%</div>
|
||||
@@ -521,7 +989,7 @@ function DualReportWindow() {
|
||||
)}
|
||||
|
||||
{reportData.response && (
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.response}>
|
||||
<div className="label-text">回应速度</div>
|
||||
<h2 className="hero-title">你说,我在</h2>
|
||||
<div className="response-pulse-container">
|
||||
@@ -558,7 +1026,7 @@ function DualReportWindow() {
|
||||
)}
|
||||
|
||||
{reportData.streak && (
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.streak}>
|
||||
<div className="label-text">聊天火花</div>
|
||||
<h2 className="hero-title">最长连续聊天</h2>
|
||||
<div className="streak-spark-visual premium">
|
||||
@@ -596,7 +1064,7 @@ function DualReportWindow() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section word-cloud-section">
|
||||
<section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
|
||||
<div className="label-text">常用语</div>
|
||||
<h2 className="hero-title">{yearTitle}常用语</h2>
|
||||
|
||||
@@ -640,7 +1108,7 @@ function DualReportWindow() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.stats}>
|
||||
<div className="label-text">年度统计</div>
|
||||
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
||||
<div className="dual-stat-grid">
|
||||
@@ -664,7 +1132,7 @@ function DualReportWindow() {
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">我常用的表情</div>
|
||||
{myEmojiUrl ? (
|
||||
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
|
||||
<img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
|
||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}} />
|
||||
@@ -677,7 +1145,7 @@ function DualReportWindow() {
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
||||
{friendEmojiUrl ? (
|
||||
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
|
||||
<img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
|
||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}} />
|
||||
@@ -690,7 +1158,7 @@ function DualReportWindow() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<section className="section" ref={sectionRefs.ending}>
|
||||
<div className="label-text">尾声</div>
|
||||
<h2 className="hero-title">谢谢你一直在</h2>
|
||||
<p className="hero-desc">愿我们继续把故事写下去</p>
|
||||
|
||||
+1082
-239
@@ -1,14 +1,18 @@
|
||||
.export-board-page {
|
||||
min-height: calc(100% + 48px);
|
||||
height: auto;
|
||||
margin: -24px;
|
||||
padding: 20px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: -24px -24px 0;
|
||||
padding: 18px 22px 12px;
|
||||
background:
|
||||
radial-gradient(1200px 520px at 6% -8%, color-mix(in srgb, var(--primary) 11%, transparent), transparent 65%),
|
||||
radial-gradient(860px 420px at 90% 0%, color-mix(in srgb, var(--primary) 7%, transparent), transparent 66%),
|
||||
var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
overflow-y: hidden;
|
||||
animation: exportPageEnter 0.34s ease-out;
|
||||
|
||||
.spin {
|
||||
animation: exportSpin 1s linear infinite;
|
||||
@@ -18,12 +22,16 @@
|
||||
.export-top-panel {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 55;
|
||||
animation: exportSectionReveal 0.34s ease both;
|
||||
}
|
||||
|
||||
.export-top-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-section-title-row {
|
||||
@@ -31,6 +39,7 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
animation: exportSectionReveal 0.38s ease both;
|
||||
}
|
||||
|
||||
.session-load-detail-entry {
|
||||
@@ -58,6 +67,12 @@
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-entry-icon {
|
||||
@@ -98,6 +113,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-load-detail-entry.open .session-load-detail-entry-bar {
|
||||
animation: none;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
@keyframes sessionLoadDetailBars {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.72);
|
||||
@@ -112,9 +132,10 @@
|
||||
|
||||
.export-section-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.section-info-tooltip {
|
||||
@@ -177,8 +198,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2200;
|
||||
z-index: 7800;
|
||||
padding: 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.session-load-detail-modal {
|
||||
@@ -191,6 +213,7 @@
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.session-load-detail-header {
|
||||
@@ -485,8 +508,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2250;
|
||||
z-index: 7850;
|
||||
padding: 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.session-mutual-friends-modal {
|
||||
@@ -499,6 +523,7 @@
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.session-mutual-friends-header {
|
||||
@@ -716,14 +741,23 @@
|
||||
--top-inline-control-height: 34px;
|
||||
flex: 0 1 980px;
|
||||
width: min(980px, 100%);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 75%, var(--bg-primary)) 0%, var(--card-bg) 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
|
||||
padding: 13px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
animation: exportSectionReveal 0.4s ease both;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 11px;
|
||||
@@ -759,12 +793,13 @@
|
||||
.path-value {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 86%, var(--bg-primary));
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.path-link {
|
||||
@@ -816,6 +851,11 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
z-index: 40;
|
||||
isolation: isolate;
|
||||
|
||||
&.open {
|
||||
z-index: 3200;
|
||||
}
|
||||
}
|
||||
|
||||
.more-export-settings-control {
|
||||
@@ -836,12 +876,13 @@
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease, transform 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,10 +900,11 @@
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -877,16 +919,18 @@
|
||||
right: auto;
|
||||
width: clamp(300px, 36vw, 420px);
|
||||
max-width: calc(100vw - 40px);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 22px 38px rgba(15, 23, 42, 0.2);
|
||||
padding: 6px;
|
||||
z-index: 3000;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end;
|
||||
@@ -899,6 +943,7 @@
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start;
|
||||
animation: exportPopoverEnter 0.18s ease both;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,12 +953,14 @@
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 3px;
|
||||
min-height: 58px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -939,7 +986,7 @@
|
||||
|
||||
.layout-prefix-toggle {
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
padding: 11px 10px 10px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1012,9 +1059,10 @@
|
||||
min-width: 92px;
|
||||
min-height: 42px;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 80%, var(--bg-primary)) 0%, var(--card-bg) 100%);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
display: inline-flex;
|
||||
@@ -1026,12 +1074,15 @@
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
|
||||
transition: border-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease, background 0.14s ease;
|
||||
animation: exportSectionReveal 0.46s ease both;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color));
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--card-bg));
|
||||
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
&.has-alert {
|
||||
@@ -1047,8 +1098,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2300;
|
||||
z-index: 7700;
|
||||
padding: 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.export-defaults-modal {
|
||||
@@ -1061,6 +1113,7 @@
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.export-defaults-modal-header {
|
||||
@@ -1120,19 +1173,31 @@
|
||||
|
||||
.content-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(142px, 1fr));
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
animation: exportSectionReveal 0.52s ease both;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
padding: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 13px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--card-bg) 100%);
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06);
|
||||
padding: 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, background 0.16s ease;
|
||||
animation: exportCardReveal 0.42s ease both;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
box-shadow: 0 16px 24px rgba(15, 23, 42, 0.11);
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--primary) 4%, var(--card-bg));
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
@@ -1145,9 +1210,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-title-meta {
|
||||
@@ -1172,12 +1237,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1185,10 +1250,10 @@
|
||||
.card-export-btn {
|
||||
margin-top: auto;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1203,6 +1268,8 @@
|
||||
|
||||
&.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 24%, transparent);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@@ -1215,6 +1282,7 @@
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 94%, var(--primary) 6%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -1240,6 +1308,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.content-card-grid .content-card:nth-child(1) { animation-delay: 0.03s; }
|
||||
.content-card-grid .content-card:nth-child(2) { animation-delay: 0.07s; }
|
||||
.content-card-grid .content-card:nth-child(3) { animation-delay: 0.11s; }
|
||||
.content-card-grid .content-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
.content-card-grid .content-card:nth-child(5) { animation-delay: 0.19s; }
|
||||
.content-card-grid .content-card:nth-child(6) { animation-delay: 0.23s; }
|
||||
|
||||
.count-loading {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
@@ -1255,12 +1330,13 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1180;
|
||||
z-index: 7600;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.task-center-modal {
|
||||
@@ -1273,6 +1349,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.task-center-modal-header {
|
||||
@@ -1330,6 +1407,7 @@
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
animation: exportItemRise 0.2s ease both;
|
||||
|
||||
&.running {
|
||||
border-color: var(--primary);
|
||||
@@ -1563,11 +1641,23 @@
|
||||
}
|
||||
|
||||
.session-table-section {
|
||||
flex: 0 0 auto;
|
||||
min-height: 420px;
|
||||
flex: 1 1 420px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 72%, var(--bg-primary)) 0%, color-mix(in srgb, var(--card-bg) 90%, var(--bg-primary)) 100%);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.07);
|
||||
animation: exportSectionReveal 0.58s ease both;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color));
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.table-stage-hint {
|
||||
@@ -1582,6 +1672,7 @@
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
width: fit-content;
|
||||
animation: exportSectionReveal 0.35s ease both;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
@@ -1590,9 +1681,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary));
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 88%, var(--card-bg)) 0%, color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary)) 100%);
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.table-cache-meta {
|
||||
@@ -1627,7 +1719,8 @@
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
padding: 7px 6px;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
@@ -1635,18 +1728,52 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
|
||||
|
||||
.tab-btn-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
line-height: 1;
|
||||
|
||||
span:last-child {
|
||||
min-width: 24px;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 7px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
box-shadow: 0 6px 14px color-mix(in srgb, var(--primary) 24%, transparent);
|
||||
|
||||
.tab-btn-content span:last-child {
|
||||
background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary));
|
||||
color: color-mix(in srgb, var(--primary) 84%, var(--text-primary));
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 44%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1664,17 +1791,37 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.secondary-btn {
|
||||
min-height: 34px;
|
||||
border-radius: 10px;
|
||||
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
min-width: 220px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
|
||||
min-width: 240px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
@@ -1682,15 +1829,27 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary));
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1708,7 +1867,9 @@
|
||||
|
||||
.session-table-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
@@ -1719,7 +1880,7 @@
|
||||
.table-wrap {
|
||||
--contacts-native-scrollbar-compensation: 18px;
|
||||
--contacts-row-height: 76px;
|
||||
--contacts-default-visible-rows: 10;
|
||||
--contacts-default-visible-rows: 8;
|
||||
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
||||
--contacts-select-col-width: 34px;
|
||||
--contacts-avatar-col-width: 44px;
|
||||
@@ -1731,32 +1892,49 @@
|
||||
--contacts-message-col-width: 120px;
|
||||
--contacts-media-col-width: 72px;
|
||||
--contacts-action-col-width: 140px;
|
||||
--contacts-actions-sticky-width: max(var(--contacts-action-col-width), 184px);
|
||||
--contacts-table-min-width: 1200px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 12px;
|
||||
min-height: 320px;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 84%, var(--bg-primary)) 0%, var(--bg-secondary) 100%);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 22%, var(--border-color));
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff 24%, transparent),
|
||||
0 8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
.table-scroll-shell {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-scroll-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
background: var(--bg-secondary);
|
||||
padding-bottom: var(--contacts-native-scrollbar-compensation);
|
||||
margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -1765,6 +1943,9 @@
|
||||
|
||||
.table-scroll-content {
|
||||
min-width: max(100%, var(--contacts-table-min-width));
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-table-sticky {
|
||||
@@ -1902,6 +2083,7 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
flex-shrink: 0;
|
||||
backdrop-filter: saturate(115%) blur(3px);
|
||||
|
||||
&.is-draggable {
|
||||
cursor: grab;
|
||||
@@ -1938,7 +2120,7 @@
|
||||
max-width: var(--contacts-main-col-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--contacts-column-gap);
|
||||
}
|
||||
|
||||
.contacts-list-header-main-label {
|
||||
@@ -1977,8 +2159,8 @@
|
||||
}
|
||||
|
||||
.contacts-list-header-actions {
|
||||
width: max(var(--contacts-action-col-width), 184px);
|
||||
min-width: max(var(--contacts-action-col-width), 184px);
|
||||
width: var(--contacts-actions-sticky-width);
|
||||
min-width: var(--contacts-actions-sticky-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -2007,8 +2189,8 @@
|
||||
width: 100%;
|
||||
min-width: max(100%, var(--contacts-table-min-width));
|
||||
flex: 1;
|
||||
min-height: var(--contacts-default-list-height);
|
||||
height: var(--contacts-default-list-height);
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
position: relative;
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
@@ -2074,10 +2256,13 @@
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -2090,7 +2275,7 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--primary);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -2099,9 +2284,12 @@
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.selection-export-count {
|
||||
@@ -2132,6 +2320,42 @@
|
||||
|
||||
&.selected .contact-item:hover {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.selected-contiguous-bottom {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.selected-contiguous-bottom .contact-item {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow:
|
||||
inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent),
|
||||
inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--primary) 52%, transparent);
|
||||
}
|
||||
|
||||
&.selected-contiguous-top .contact-item {
|
||||
margin-top: -1px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
box-shadow:
|
||||
inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent),
|
||||
inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent),
|
||||
inset 0 -1px 0 color-mix(in srgb, var(--primary) 52%, transparent);
|
||||
}
|
||||
|
||||
&.selected-contiguous-top.selected-contiguous-bottom .contact-item {
|
||||
box-shadow:
|
||||
inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent),
|
||||
inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent);
|
||||
}
|
||||
|
||||
&.selected-contiguous-bottom .contact-item:hover,
|
||||
&.selected-contiguous-top .contact-item:hover,
|
||||
&.selected-contiguous-top.selected-contiguous-bottom .contact-item:hover {
|
||||
box-shadow: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2145,7 +2369,7 @@
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
cursor: default;
|
||||
background: var(--contacts-row-bg);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
@@ -2153,6 +2377,7 @@
|
||||
&:hover {
|
||||
background: var(--contacts-row-bg);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent);
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2294,11 +2519,13 @@
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: color 0.12s ease, background 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -2538,14 +2765,25 @@
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
gap: 4px;
|
||||
width: var(--contacts-action-col-width);
|
||||
min-width: var(--contacts-action-col-width);
|
||||
width: var(--contacts-actions-sticky-width);
|
||||
min-width: var(--contacts-actions-sticky-width);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: var(--contacts-row-bg);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -9px;
|
||||
width: 9px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--contacts-row-bg));
|
||||
}
|
||||
|
||||
.row-action-main {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
@@ -2567,11 +2805,13 @@
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -2596,7 +2836,7 @@
|
||||
|
||||
.row-export-link {
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 2px 6px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
@@ -2605,11 +2845,13 @@
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border-radius: 6px;
|
||||
transition: color 0.12s ease, background 0.12s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -2661,45 +2903,50 @@
|
||||
|
||||
.export-session-detail-overlay {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1100;
|
||||
inset: 0;
|
||||
z-index: 7900;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
padding: 12px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(1px);
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.export-session-detail-panel {
|
||||
width: min(360px, calc(100vw - 16px));
|
||||
height: calc(100vh - 40px);
|
||||
border-left: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
width: min(448px, calc(100vw - 24px));
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 24px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--bg-secondary-solid, #ffffff) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18);
|
||||
box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24);
|
||||
animation: exportDetailPanelIn 0.26s cubic-bezier(0.22, 0.8, 0.24, 1) both;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, var(--card-bg));
|
||||
|
||||
.detail-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-header-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2725,8 +2972,8 @@
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
@@ -2737,7 +2984,7 @@
|
||||
|
||||
.detail-header-id {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2745,19 +2992,21 @@
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -2779,11 +3028,16 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 94%, var(--bg-primary));
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -2793,17 +3047,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.detail-stats-meta {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@@ -2813,8 +3066,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
@@ -2824,6 +3078,7 @@
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.value {
|
||||
@@ -2840,14 +3095,15 @@
|
||||
}
|
||||
|
||||
.detail-inline-btn {
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||
color: var(--primary);
|
||||
border-radius: 6px;
|
||||
border-radius: 7px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -2855,7 +3111,8 @@
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2880,7 +3137,7 @@
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -2905,38 +3162,109 @@
|
||||
}
|
||||
|
||||
.detail-record-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary));
|
||||
transition: border-color 0.14s ease, box-shadow 0.14s ease;
|
||||
|
||||
.record-row {
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color));
|
||||
box-shadow: 0 6px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.detail-record-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
}
|
||||
|
||||
.record-export-time {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-content-pill {
|
||||
min-width: 0;
|
||||
max-width: 62%;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
.detail-record-path-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.path-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-value {
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.detail-inline-btn {
|
||||
flex-shrink: 0;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
|
||||
&.path {
|
||||
text-align: left;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 38%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-inline-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-record-open-btn {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2959,8 +3287,9 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
|
||||
font-size: 12px;
|
||||
|
||||
.db-name {
|
||||
@@ -2977,12 +3306,13 @@
|
||||
.export-session-sns-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
z-index: 7880;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.export-session-sns-dialog {
|
||||
@@ -2995,6 +3325,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
|
||||
.sns-dialog-header {
|
||||
display: flex;
|
||||
@@ -3380,22 +3711,23 @@
|
||||
.export-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(15, 18, 28, 0.48);
|
||||
backdrop-filter: blur(7px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.export-dialog {
|
||||
width: min(1080px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
width: min(860px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 40px);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 14px 14px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 26px 52px rgba(0, 0, 0, 0.3);
|
||||
padding: 16px 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -3406,20 +3738,33 @@
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 14px;
|
||||
gap: 12px;
|
||||
padding-right: 6px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 48%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding: 2px 2px 0;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3432,35 +3777,44 @@
|
||||
|
||||
.dialog-header-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.close-icon-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 86%, var(--bg-primary));
|
||||
border-radius: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease, background 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--bg-primary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
border-radius: 12px;
|
||||
padding: 13px 14px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 78%, var(--bg-primary));
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3476,21 +3830,23 @@
|
||||
}
|
||||
|
||||
.time-range-trigger {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 68%, var(--bg-secondary));
|
||||
border-radius: 999px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--primary-rgb), 0.45);
|
||||
border-color: rgba(var(--primary-rgb), 0.52);
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.time-range-arrow {
|
||||
@@ -3577,11 +3933,11 @@
|
||||
|
||||
.scope-tag {
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--primary-rgb), 0.15);
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
color: var(--primary);
|
||||
padding: 4px 10px;
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scope-count {
|
||||
@@ -3593,64 +3949,70 @@
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 7px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scope-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-primary) 68%, var(--bg-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.format-note {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.format-card {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
border-radius: 11px;
|
||||
padding: 10px 11px;
|
||||
text-align: left;
|
||||
background: var(--bg-primary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 70%, var(--bg-secondary));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
|
||||
.format-label {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.format-desc {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.35;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
border-color: color-mix(in srgb, var(--primary) 75%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 11%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3685,24 +4047,281 @@
|
||||
}
|
||||
}
|
||||
|
||||
.media-check-grid {
|
||||
margin-top: 10px;
|
||||
.media-section-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.media-selection-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
|
||||
color: color-mix(in srgb, var(--primary) 80%, var(--text-primary));
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.media-option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.media-option-card {
|
||||
position: relative;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary));
|
||||
min-height: 74px;
|
||||
padding: 10px 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
justify-content: space-between;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 46%, var(--border-color));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--primary);
|
||||
&:has(.media-option-input:focus-visible) {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 38%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: color-mix(in srgb, var(--primary) 76%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.media-option-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-option-main {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.media-option-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-option-card.active .media-option-icon {
|
||||
border-color: color-mix(in srgb, var(--primary) 58%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.media-option-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.media-option-label {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-option-desc {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.media-option-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 76%, var(--bg-secondary));
|
||||
color: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.media-option-check.active {
|
||||
border-color: color-mix(in srgb, var(--primary) 85%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 90%, var(--bg-secondary));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialog-collapse-slot {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
margin-top: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
pointer-events: none;
|
||||
transition: grid-template-rows 0.24s ease, opacity 0.18s ease, transform 0.24s ease, margin-top 0.24s ease;
|
||||
}
|
||||
|
||||
.dialog-collapse-slot.open {
|
||||
grid-template-rows: 1fr;
|
||||
margin-top: 12px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dialog-collapse-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-size-subsection {
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 64%, var(--bg-secondary));
|
||||
padding: 11px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.file-size-subsection.disabled {
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.file-size-subsection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-size-heading {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-size-current {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.file-size-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-size-preset-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-size-preset-btn {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
min-height: 29px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: color-mix(in srgb, var(--primary) 72%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
|
||||
color: color-mix(in srgb, var(--primary) 82%, var(--text-primary));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-input-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
|
||||
input {
|
||||
width: 128px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 10px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--primary) 70%, var(--border-color));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
border-color: color-mix(in srgb, var(--border-color) 92%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3732,14 +4351,15 @@
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 45%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&.on {
|
||||
background: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 90%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3761,80 +4381,96 @@
|
||||
|
||||
.display-name-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.display-name-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
min-height: 86px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--bg-primary);
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(var(--primary-rgb), 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 44%, var(--border-color));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
border-color: color-mix(in srgb, var(--primary) 76%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 11%, var(--bg-secondary));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--card-bg);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
var(--card-bg) 38%
|
||||
);
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.secondary-btn {
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
min-width: 168px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -3844,12 +4480,15 @@
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 85%, var(--bg-primary));
|
||||
color: var(--text-primary);
|
||||
min-width: 112px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--bg-secondary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4081,6 +4720,96 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@keyframes exportPageEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportSectionReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportCardReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.987);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportOverlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportModalPopIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportDetailPanelIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(16px) scale(0.995);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportPopoverEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.985);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportItemRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -4124,6 +4853,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.export-board-page,
|
||||
.export-top-panel,
|
||||
.export-section-title-row,
|
||||
.global-export-controls,
|
||||
.task-center-card,
|
||||
.content-card-grid,
|
||||
.content-card,
|
||||
.session-table-section,
|
||||
.table-stage-hint,
|
||||
.dialog-collapse-slot {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.animated-ellipsis,
|
||||
.session-load-detail-entry-bar,
|
||||
.task-center-card-badge,
|
||||
.spin {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.session-load-detail-overlay,
|
||||
.task-center-modal-overlay,
|
||||
.export-defaults-modal-overlay,
|
||||
.session-mutual-friends-overlay,
|
||||
.export-session-detail-overlay,
|
||||
.session-load-detail-modal,
|
||||
.task-center-modal,
|
||||
.export-defaults-modal,
|
||||
.session-mutual-friends-modal,
|
||||
.export-session-detail-panel,
|
||||
.export-session-sns-overlay,
|
||||
.export-session-sns-dialog,
|
||||
.task-card,
|
||||
.layout-dropdown.open {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.export-top-bar {
|
||||
gap: 10px;
|
||||
@@ -4142,11 +4914,11 @@
|
||||
}
|
||||
|
||||
.display-name-options {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.media-check-grid {
|
||||
gap: 8px 12px;
|
||||
.media-option-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4292,6 +5064,77 @@
|
||||
width: calc(100vw - 20px);
|
||||
max-height: calc(100vh - 20px);
|
||||
padding: 12px 10px 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
margin-bottom: 10px;
|
||||
|
||||
h3 {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-section {
|
||||
padding: 11px 11px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header-action {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-range-trigger {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.display-name-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-input-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.35fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.secondary-btn,
|
||||
.primary-btn {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.media-option-card {
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.file-size-subsection-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
|
||||
+281
-78
@@ -12,6 +12,7 @@ import {
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
File as FileIcon,
|
||||
FolderOpen,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
@@ -67,7 +68,7 @@ import './ExportPage.scss'
|
||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
type ContentCardType = ContentType | 'sns'
|
||||
type SnsRankMode = 'likes' | 'comments'
|
||||
|
||||
@@ -88,6 +89,8 @@ interface ExportOptions {
|
||||
exportVoices: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
exportFiles: boolean
|
||||
maxFileSizeMb: number
|
||||
exportVoiceAsText: boolean
|
||||
excelCompactColumns: boolean
|
||||
txtColumns: string[]
|
||||
@@ -181,6 +184,7 @@ interface ExportDialogState {
|
||||
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
@@ -195,8 +199,10 @@ const contentTypeLabels: Record<ContentType, string> = {
|
||||
voice: '语音',
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
emoji: '表情包'
|
||||
emoji: '表情包',
|
||||
file: '文件'
|
||||
}
|
||||
const FILE_SIZE_PRESETS_MB = [0, 100, 200, 500, 1024] as const
|
||||
|
||||
const backgroundTaskSourceLabels: Record<string, string> = {
|
||||
export: '导出页',
|
||||
@@ -311,9 +317,7 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance =>
|
||||
write: performance?.stages.write || 0,
|
||||
other: performance?.stages.other || 0
|
||||
},
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
|
||||
)
|
||||
sessions: { ...(performance?.sessions || {}) }
|
||||
})
|
||||
|
||||
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
||||
@@ -333,6 +337,18 @@ const applyProgressToTaskPerformance = (
|
||||
const sessionId = String(payload.currentSessionId || '').trim()
|
||||
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
||||
|
||||
const currentPerformance = task.performance
|
||||
const currentSession = currentPerformance?.sessions?.[sessionId]
|
||||
if (
|
||||
payload.phase !== 'complete' &&
|
||||
currentSession &&
|
||||
currentSession.lastPhase === payload.phase &&
|
||||
typeof currentSession.lastPhaseStartedAt === 'number' &&
|
||||
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
|
||||
) {
|
||||
return currentPerformance
|
||||
}
|
||||
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
||||
const existing = performance.sessions[sessionId]
|
||||
@@ -368,7 +384,9 @@ const applyProgressToTaskPerformance = (
|
||||
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
||||
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
for (const session of Object.values(performance.sessions)) {
|
||||
const nextSessions: Record<string, TaskSessionPerformance> = {}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
const session: TaskSessionPerformance = { ...sourceSession }
|
||||
if (session.finishedAt) continue
|
||||
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||
@@ -378,7 +396,13 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance
|
||||
session.finishedAt = now
|
||||
session.lastPhase = undefined
|
||||
session.lastPhaseStartedAt = undefined
|
||||
nextSessions[sessionId] = session
|
||||
}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
if (nextSessions[sessionId]) continue
|
||||
nextSessions[sessionId] = { ...sourceSession }
|
||||
}
|
||||
performance.sessions = nextSessions
|
||||
return performance
|
||||
}
|
||||
|
||||
@@ -1188,16 +1212,18 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
||||
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)'
|
||||
|
||||
return (
|
||||
<div className="write-layout-control" ref={containerRef}>
|
||||
<div className={`write-layout-control ${isOpen ? 'open' : ''}`} ref={containerRef}>
|
||||
<span className="control-label">写入目录方式</span>
|
||||
<button
|
||||
className={`layout-trigger ${isOpen ? 'active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{writeLayoutLabel}
|
||||
</button>
|
||||
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`}>
|
||||
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`} role="listbox" aria-label="写入目录方式">
|
||||
{writeLayoutOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -1314,7 +1340,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
}: TaskCenterModalProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className="task-center-modal-overlay"
|
||||
onClick={onClose}
|
||||
@@ -1511,7 +1537,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1598,7 +1625,8 @@ function ExportPage() {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
@@ -1617,7 +1645,9 @@ function ExportPage() {
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportEmojis: true,
|
||||
exportFiles: true,
|
||||
maxFileSizeMb: 200,
|
||||
exportVoiceAsText: false,
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
@@ -2281,7 +2311,8 @@ function ExportPage() {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
@@ -2310,12 +2341,14 @@ function ExportPage() {
|
||||
(savedMedia?.images ?? prev.exportImages) ||
|
||||
(savedMedia?.voices ?? prev.exportVoices) ||
|
||||
(savedMedia?.videos ?? prev.exportVideos) ||
|
||||
(savedMedia?.emojis ?? prev.exportEmojis)
|
||||
(savedMedia?.emojis ?? prev.exportEmojis) ||
|
||||
(savedMedia?.files ?? prev.exportFiles)
|
||||
),
|
||||
exportImages: savedMedia?.images ?? prev.exportImages,
|
||||
exportVoices: savedMedia?.voices ?? prev.exportVoices,
|
||||
exportVideos: savedMedia?.videos ?? prev.exportVideos,
|
||||
exportEmojis: savedMedia?.emojis ?? prev.exportEmojis,
|
||||
exportFiles: savedMedia?.files ?? prev.exportFiles,
|
||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||
txtColumns,
|
||||
@@ -4088,12 +4121,15 @@ function ExportPage() {
|
||||
exportDefaultMedia.images ||
|
||||
exportDefaultMedia.voices ||
|
||||
exportDefaultMedia.videos ||
|
||||
exportDefaultMedia.emojis
|
||||
exportDefaultMedia.emojis ||
|
||||
exportDefaultMedia.files
|
||||
),
|
||||
exportImages: exportDefaultMedia.images,
|
||||
exportVoices: exportDefaultMedia.voices,
|
||||
exportVideos: exportDefaultMedia.videos,
|
||||
exportEmojis: exportDefaultMedia.emojis,
|
||||
exportFiles: exportDefaultMedia.files,
|
||||
maxFileSizeMb: prev.maxFileSizeMb,
|
||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||
exportConcurrency: exportDefaultConcurrency,
|
||||
@@ -4111,12 +4147,14 @@ function ExportPage() {
|
||||
next.exportVoices = false
|
||||
next.exportVideos = false
|
||||
next.exportEmojis = false
|
||||
next.exportFiles = false
|
||||
} else {
|
||||
next.exportMedia = true
|
||||
next.exportImages = payload.contentType === 'image'
|
||||
next.exportVoices = payload.contentType === 'voice'
|
||||
next.exportVideos = payload.contentType === 'video'
|
||||
next.exportEmojis = payload.contentType === 'emoji'
|
||||
next.exportFiles = payload.contentType === 'file'
|
||||
next.exportVoiceAsText = false
|
||||
}
|
||||
}
|
||||
@@ -4335,7 +4373,13 @@ function ExportPage() {
|
||||
|
||||
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
|
||||
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
||||
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
const exportMediaEnabled = Boolean(
|
||||
options.exportImages ||
|
||||
options.exportVoices ||
|
||||
options.exportVideos ||
|
||||
options.exportEmojis ||
|
||||
options.exportFiles
|
||||
)
|
||||
|
||||
const base: ElectronExportOptions = {
|
||||
format: options.format,
|
||||
@@ -4345,6 +4389,8 @@ function ExportPage() {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
txtColumns: options.txtColumns,
|
||||
@@ -4375,7 +4421,8 @@ function ExportPage() {
|
||||
exportImages: false,
|
||||
exportVoices: false,
|
||||
exportVideos: false,
|
||||
exportEmojis: false
|
||||
exportEmojis: false,
|
||||
exportFiles: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4387,6 +4434,7 @@ function ExportPage() {
|
||||
exportVoices: contentType === 'voice',
|
||||
exportVideos: contentType === 'video',
|
||||
exportEmojis: contentType === 'emoji',
|
||||
exportFiles: contentType === 'file',
|
||||
exportVoiceAsText: false
|
||||
}
|
||||
}
|
||||
@@ -4452,6 +4500,7 @@ function ExportPage() {
|
||||
if (opts.exportVoices) labels.push('语音')
|
||||
if (opts.exportVideos) labels.push('视频')
|
||||
if (opts.exportEmojis) labels.push('表情包')
|
||||
if (opts.exportFiles) labels.push('文件')
|
||||
}
|
||||
return Array.from(new Set(labels)).join('、')
|
||||
}, [])
|
||||
@@ -4507,6 +4556,7 @@ function ExportPage() {
|
||||
if (opts.exportImages) types.push('image')
|
||||
if (opts.exportVideos) types.push('video')
|
||||
if (opts.exportEmojis) types.push('emoji')
|
||||
if (opts.exportFiles) types.push('file')
|
||||
}
|
||||
return types
|
||||
}
|
||||
@@ -4697,7 +4747,7 @@ function ExportPage() {
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, 100)
|
||||
}, 180)
|
||||
})
|
||||
}
|
||||
if (next.payload.scope === 'sns') {
|
||||
@@ -4937,7 +4987,8 @@ function ExportPage() {
|
||||
images: options.exportImages,
|
||||
voices: options.exportVoices,
|
||||
videos: options.exportVideos,
|
||||
emojis: options.exportEmojis
|
||||
emojis: options.exportEmojis,
|
||||
files: options.exportFiles
|
||||
})
|
||||
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
|
||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||
@@ -6445,6 +6496,10 @@ function ExportPage() {
|
||||
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||
const shouldShowMediaSection = !isContentScopeDialog
|
||||
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
isSessionScopeDialog ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
)
|
||||
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
(isSessionScopeDialog && options.exportImages) ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
@@ -6454,6 +6509,80 @@ function ExportPage() {
|
||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||
? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat)
|
||||
: (formatOptions.find(option => option.value === options.format)?.label ?? options.format)
|
||||
const sessionMediaOptions = [
|
||||
{
|
||||
key: 'images',
|
||||
label: '图片',
|
||||
desc: '聊天图片与缩略图',
|
||||
icon: ImageIcon,
|
||||
checked: options.exportImages,
|
||||
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportImages: checked }))
|
||||
},
|
||||
{
|
||||
key: 'voices',
|
||||
label: '语音',
|
||||
desc: '语音消息文件',
|
||||
icon: Mic,
|
||||
checked: options.exportVoices,
|
||||
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVoices: checked }))
|
||||
},
|
||||
{
|
||||
key: 'videos',
|
||||
label: '视频',
|
||||
desc: '聊天视频与封面',
|
||||
icon: Video,
|
||||
checked: options.exportVideos,
|
||||
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVideos: checked }))
|
||||
},
|
||||
{
|
||||
key: 'emojis',
|
||||
label: '表情包',
|
||||
desc: '静态与动态表情',
|
||||
icon: MessageSquare,
|
||||
checked: options.exportEmojis,
|
||||
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportEmojis: checked }))
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: '文件',
|
||||
desc: '文档与附件',
|
||||
icon: FileIcon,
|
||||
checked: options.exportFiles,
|
||||
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportFiles: checked }))
|
||||
}
|
||||
]
|
||||
const snsMediaOptions = [
|
||||
{
|
||||
key: 'images',
|
||||
label: '图片',
|
||||
desc: '朋友圈图片',
|
||||
icon: ImageIcon,
|
||||
checked: snsExportImages,
|
||||
onToggle: (checked: boolean) => setSnsExportImages(checked)
|
||||
},
|
||||
{
|
||||
key: 'live-photos',
|
||||
label: '实况图',
|
||||
desc: 'Live Photo',
|
||||
icon: Aperture,
|
||||
checked: snsExportLivePhotos,
|
||||
onToggle: (checked: boolean) => setSnsExportLivePhotos(checked)
|
||||
},
|
||||
{
|
||||
key: 'videos',
|
||||
label: '视频',
|
||||
desc: '朋友圈视频',
|
||||
icon: Video,
|
||||
checked: snsExportVideos,
|
||||
onToggle: (checked: boolean) => setSnsExportVideos(checked)
|
||||
}
|
||||
]
|
||||
const dialogMediaOptions = exportDialog.scope === 'sns' ? snsMediaOptions : sessionMediaOptions
|
||||
const mediaSelectionSummaryLabel = `已选择 ${dialogMediaOptions.filter(option => option.checked).length}/${dialogMediaOptions.length}`
|
||||
const voiceAsTextStatusLabel = options.exportVoices
|
||||
? '已勾选导出语音:会同时导出语音文件,并在文本中追加语音转写结果。'
|
||||
: '未勾选导出语音时,仅在文本里追加语音转写结果,不导出语音文件。'
|
||||
const fileSizeLimitLabel = options.maxFileSizeMb <= 0 ? '不限' : `${options.maxFileSizeMb} MB`
|
||||
const shouldShowDisplayNameSection = !(
|
||||
exportDialog.scope === 'sns' ||
|
||||
(
|
||||
@@ -6472,8 +6601,9 @@ function ExportPage() {
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||
const hasFilteredContacts = filteredContacts.length > 0
|
||||
const CONTACTS_ACTION_STICKY_WIDTH = 184
|
||||
const contactsTableMinWidth = useMemo(() => {
|
||||
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12)
|
||||
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + CONTACTS_ACTION_STICKY_WIDTH + (8 * 12)
|
||||
const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
|
||||
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
||||
return baseWidth + snsWidth + mutualFriendsWidth
|
||||
@@ -6664,7 +6794,7 @@ function ExportPage() {
|
||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||
}, [])
|
||||
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
|
||||
const renderContactRow = useCallback((index: number, contact: ContactInfo) => {
|
||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||
const canExport = Boolean(matchedSession?.hasSession)
|
||||
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
||||
@@ -6730,8 +6860,20 @@ function ExportPage() {
|
||||
: contact.type === 'group'
|
||||
? '打开群聊'
|
||||
: '打开对话'
|
||||
const previousContact = index > 0 ? filteredContacts[index - 1] : null
|
||||
const nextContact = index < filteredContacts.length - 1 ? filteredContacts[index + 1] : null
|
||||
const previousCanExport = Boolean(previousContact && sessionRowByUsername.get(previousContact.username)?.hasSession)
|
||||
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
|
||||
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
|
||||
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
|
||||
const rowClassName = [
|
||||
'contact-row',
|
||||
checked ? 'selected' : '',
|
||||
checked && previousSelected ? 'selected-contiguous-top' : '',
|
||||
checked && nextSelected ? 'selected-contiguous-bottom' : ''
|
||||
].filter(Boolean).join(' ')
|
||||
return (
|
||||
<div className={`contact-row ${checked ? 'selected' : ''}`}>
|
||||
<div className={rowClassName}>
|
||||
<div className="contact-item">
|
||||
<div className="row-left-sticky">
|
||||
<div className="row-select-cell">
|
||||
@@ -6880,6 +7022,7 @@ function ExportPage() {
|
||||
</div>
|
||||
)
|
||||
}, [
|
||||
filteredContacts,
|
||||
lastExportBySession,
|
||||
navigate,
|
||||
nowTick,
|
||||
@@ -6955,11 +7098,12 @@ function ExportPage() {
|
||||
setExportDefaultMedia(mediaPatch)
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis),
|
||||
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files),
|
||||
exportImages: mediaPatch.images,
|
||||
exportVoices: mediaPatch.voices,
|
||||
exportVideos: mediaPatch.videos,
|
||||
exportEmojis: mediaPatch.emojis
|
||||
exportEmojis: mediaPatch.emojis,
|
||||
exportFiles: mediaPatch.files
|
||||
}))
|
||||
}
|
||||
if (typeof patch.voiceAsText === 'boolean') {
|
||||
@@ -7048,7 +7192,7 @@ function ExportPage() {
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
/>
|
||||
|
||||
{isExportDefaultsModalOpen && (
|
||||
{isExportDefaultsModalOpen && createPortal(
|
||||
<div
|
||||
className="export-defaults-modal-overlay"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
@@ -7086,7 +7230,8 @@ function ExportPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div className="export-section-title-row">
|
||||
@@ -7171,7 +7316,7 @@ function ExportPage() {
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
className={`session-load-detail-entry ${isSessionLoadDetailActive ? 'active' : ''}`}
|
||||
className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()}
|
||||
type="button"
|
||||
onClick={() => setShowSessionLoadDetailModal(true)}
|
||||
>
|
||||
@@ -7381,7 +7526,7 @@ function ExportPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSessionLoadDetailModal && (
|
||||
{showSessionLoadDetailModal && createPortal(
|
||||
<div
|
||||
className="session-load-detail-overlay"
|
||||
onClick={() => setShowSessionLoadDetailModal(false)}
|
||||
@@ -7616,10 +7761,11 @@ function ExportPage() {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && (
|
||||
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && createPortal(
|
||||
<div
|
||||
className="session-mutual-friends-overlay"
|
||||
onClick={closeSessionMutualFriendsDialog}
|
||||
@@ -7702,10 +7848,11 @@ function ExportPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showSessionDetailPanel && (
|
||||
{showSessionDetailPanel && createPortal(
|
||||
<div
|
||||
className="export-session-detail-overlay"
|
||||
onClick={closeSessionDetailPanel}
|
||||
@@ -7807,19 +7954,15 @@ function ExportPage() {
|
||||
<div className="detail-record-list">
|
||||
{currentSessionExportRecords.map((record, index) => (
|
||||
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
|
||||
<div className="record-row">
|
||||
<span className="label">导出时间</span>
|
||||
<span className="value">{formatYmdHmDateTime(record.exportTime)}</span>
|
||||
<div className="detail-record-head">
|
||||
<span className="record-export-time">{formatYmdHmDateTime(record.exportTime)}</span>
|
||||
<span className="record-content-pill" title={record.content}>{record.content}</span>
|
||||
</div>
|
||||
<div className="record-row">
|
||||
<span className="label">导出内容</span>
|
||||
<span className="value">{record.content}</span>
|
||||
</div>
|
||||
<div className="record-row">
|
||||
<span className="label">导出目录</span>
|
||||
<span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
|
||||
<div className="detail-record-path-row">
|
||||
<span className="path-label">导出目录</span>
|
||||
<span className="path-value" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
|
||||
<button
|
||||
className="detail-inline-btn"
|
||||
className="detail-inline-btn detail-record-open-btn"
|
||||
type="button"
|
||||
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
|
||||
>
|
||||
@@ -7835,7 +7978,7 @@ function ExportPage() {
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<MessageSquare size={14} />
|
||||
<span>消息统计(导出口径)</span>
|
||||
<span>消息统计</span>
|
||||
</div>
|
||||
<div className="detail-stats-meta">
|
||||
{isRefreshingSessionDetailStats
|
||||
@@ -8018,7 +8161,8 @@ function ExportPage() {
|
||||
<div className="detail-empty">暂无详情</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<ContactSnsTimelineDialog
|
||||
@@ -8147,45 +8291,103 @@ function ExportPage() {
|
||||
|
||||
{shouldShowMediaSection && (
|
||||
<div className="dialog-section">
|
||||
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
|
||||
<div className="media-check-grid">
|
||||
{exportDialog.scope === 'sns' ? (
|
||||
<>
|
||||
<label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> 图片</label>
|
||||
<label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> 实况图</label>
|
||||
<label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> 视频</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label>
|
||||
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
|
||||
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
|
||||
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label>
|
||||
</>
|
||||
)}
|
||||
<div className="section-header-action media-section-header">
|
||||
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
|
||||
<span className="media-selection-pill">{mediaSelectionSummaryLabel}</span>
|
||||
</div>
|
||||
{exportDialog.scope === 'sns' && (
|
||||
<div className="format-note">全不勾选时仅导出文本信息,不导出媒体文件。</div>
|
||||
<div className="media-option-grid">
|
||||
{dialogMediaOptions.map(option => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<label key={option.key} className={`media-option-card ${option.checked ? 'active' : ''}`}>
|
||||
<input
|
||||
className="media-option-input"
|
||||
type="checkbox"
|
||||
checked={option.checked}
|
||||
onChange={event => option.onToggle(event.target.checked)}
|
||||
/>
|
||||
<span className="media-option-main">
|
||||
<span className="media-option-icon">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="media-option-text">
|
||||
<span className="media-option-label">{option.label}</span>
|
||||
<span className="media-option-desc">{option.desc}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={`media-option-check ${option.checked ? 'active' : ''}`}>
|
||||
<Check size={12} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{exportDialog.scope !== 'sns' && (
|
||||
<div
|
||||
className={`dialog-collapse-slot ${options.exportFiles ? 'open' : ''}`}
|
||||
aria-hidden={!options.exportFiles}
|
||||
>
|
||||
<div className="dialog-collapse-inner">
|
||||
<div className="file-size-subsection">
|
||||
<div className="file-size-subsection-header">
|
||||
<div className="file-size-heading">文件大小上限</div>
|
||||
<div className="file-size-current">{fileSizeLimitLabel}</div>
|
||||
</div>
|
||||
<div className="file-size-note">
|
||||
文件导出优先使用消息中的 MD5 做校验;设置上限后,只导出不超过该值的文件。
|
||||
</div>
|
||||
<div className="file-size-preset-row">
|
||||
{FILE_SIZE_PRESETS_MB.map(preset => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className={`file-size-preset-btn ${options.maxFileSizeMb === preset ? 'active' : ''}`}
|
||||
onClick={() => setOptions(prev => ({ ...prev, maxFileSizeMb: preset }))}
|
||||
>
|
||||
{preset === 0 ? '不限' : `${preset}MB`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dialog-input-row">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={options.maxFileSizeMb}
|
||||
onChange={event => {
|
||||
const raw = Number(event.target.value)
|
||||
setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 }))
|
||||
}}
|
||||
/>
|
||||
<span>MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowImageDeepSearchToggle && (
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>缺图时深度搜索</h4>
|
||||
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
||||
{shouldRenderImageDeepSearchToggle && (
|
||||
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
|
||||
<div className="dialog-collapse-inner">
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>缺图时深度搜索</h4>
|
||||
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
|
||||
aria-pressed={options.imageDeepSearchOnMiss}
|
||||
aria-label="切换缺图时深度搜索"
|
||||
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
|
||||
>
|
||||
<span className="dialog-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
|
||||
aria-pressed={options.imageDeepSearchOnMiss}
|
||||
aria-label="切换缺图时深度搜索"
|
||||
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
|
||||
>
|
||||
<span className="dialog-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -8196,6 +8398,7 @@ function ExportPage() {
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>语音转文字</h4>
|
||||
<div className="format-note">默认状态跟随更多导出设置中的语音转文字开关。</div>
|
||||
<div className="format-note">{voiceAsTextStatusLabel}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2934,3 +2934,488 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
.anti-revoke-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%,
|
||||
color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.anti-revoke-hero-main {
|
||||
min-width: 240px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-metrics {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(112px, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.anti-revoke-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%);
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-total {
|
||||
border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%);
|
||||
}
|
||||
|
||||
&.is-installed {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%);
|
||||
|
||||
.value {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||
|
||||
.value {
|
||||
color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary));
|
||||
|
||||
.value {
|
||||
color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-control-card {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%);
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.anti-revoke-search {
|
||||
min-width: 280px;
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%);
|
||||
|
||||
input {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.anti-revoke-btn-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.anti-revoke-batch-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.anti-revoke-selected-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar-actions .btn,
|
||||
.anti-revoke-batch-actions .btn {
|
||||
border-radius: 10px;
|
||||
padding-inline: 14px;
|
||||
border-width: 1px;
|
||||
min-height: 36px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.anti-revoke-summary {
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary));
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-list {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-primary);
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.anti-revoke-list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24px;
|
||||
}
|
||||
|
||||
.anti-revoke-empty {
|
||||
padding: 44px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.anti-revoke-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.anti-revoke-check {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input[type='checkbox'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-indicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
|
||||
color: var(--on-primary, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + .check-indicator {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox']:focus-visible + .check-indicator {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input[type='checkbox']:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type='checkbox']:disabled + .check-indicator {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.installed {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.not-installed {
|
||||
color: var(--text-secondary);
|
||||
border-color: color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
|
||||
.status-dot {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&.checking {
|
||||
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--primary);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-error {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.anti-revoke-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-metrics {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.anti-revoke-batch-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-selected-count {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.anti-revoke-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-row-status {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+518
-8
@@ -15,11 +15,12 @@ import {
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
@@ -70,6 +71,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setShowUpdateDialog,
|
||||
} = useAppStore()
|
||||
|
||||
const chatSessions = useChatStore((state) => state.sessions)
|
||||
const setChatSessions = useChatStore((state) => state.setSessions)
|
||||
const resetChatStore = useChatStore((state) => state.reset)
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -138,6 +141,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
|
||||
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||
const [launchAtStartup, setLaunchAtStartup] = useState(false)
|
||||
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
|
||||
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
|
||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||
@@ -162,6 +168,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
@@ -196,6 +203,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||
const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false)
|
||||
const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false)
|
||||
const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false)
|
||||
const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
@@ -337,6 +351,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||
const savedUpdateChannel = await configService.getUpdateChannel()
|
||||
@@ -386,15 +401,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setNotificationFilterMode(savedNotificationFilterMode)
|
||||
setNotificationFilterList(savedNotificationFilterList)
|
||||
setMessagePushEnabled(savedMessagePushEnabled)
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
setQuoteLayout(savedQuoteLayout)
|
||||
if (savedUpdateChannel) {
|
||||
setUpdateChannel(savedUpdateChannel)
|
||||
} else {
|
||||
const currentVersion = await window.electronAPI.app.getVersion()
|
||||
if (/-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||
if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||
setUpdateChannel('preview')
|
||||
} else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||
} else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||
setUpdateChannel('dev')
|
||||
} else {
|
||||
setUpdateChannel('stable')
|
||||
@@ -428,6 +446,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
|
||||
|
||||
const handleLaunchAtStartupChange = async (enabled: boolean) => {
|
||||
if (isUpdatingLaunchAtStartup) return
|
||||
|
||||
try {
|
||||
setIsUpdatingLaunchAtStartup(true)
|
||||
const result = await window.electronAPI.app.setLaunchAtStartup(enabled)
|
||||
setLaunchAtStartup(result.enabled)
|
||||
setLaunchAtStartupSupported(result.supported)
|
||||
setLaunchAtStartupReason(result.reason || '')
|
||||
|
||||
if (result.success) {
|
||||
showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true)
|
||||
return
|
||||
}
|
||||
|
||||
showMessage(result.error || result.reason || '设置开机自启动失败', false)
|
||||
} catch (e: any) {
|
||||
showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsUpdatingLaunchAtStartup(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||
try {
|
||||
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||
@@ -555,6 +596,248 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const normalizeSessionIds = (sessionIds: string[]): string[] =>
|
||||
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
|
||||
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
|
||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = getCurrentAntiRevokeSessionIds()
|
||||
if (current.length > 0) return current
|
||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
throw new Error(sessionsResult.error || '加载会话失败')
|
||||
}
|
||||
setChatSessions(sessionsResult.sessions)
|
||||
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
||||
}
|
||||
|
||||
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: true,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeRefreshing(true)
|
||||
try {
|
||||
const targetIds = normalizeSessionIds(
|
||||
sessionIds && sessionIds.length > 0
|
||||
? sessionIds
|
||||
: await ensureAntiRevokeSessionsLoaded()
|
||||
)
|
||||
if (targetIds.length === 0) {
|
||||
setAntiRevokeStatusMap({})
|
||||
showMessage('暂无可检查的会话', true)
|
||||
return
|
||||
}
|
||||
markAntiRevokeRowsLoading(targetIds)
|
||||
|
||||
const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '防撤回状态检查失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of targetIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = targetIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.installed === true,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '状态查询失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount })
|
||||
showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallAntiRevokeTriggers = async () => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||
if (sessionIds.length === 0) {
|
||||
showMessage('请先选择至少一个会话', false)
|
||||
return
|
||||
}
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeInstalling(true)
|
||||
try {
|
||||
markAntiRevokeRowsLoading(sessionIds)
|
||||
const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '批量安装失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = sessionIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.success ? true : next[sessionId]?.installed,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '安装失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount })
|
||||
showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`批量安装失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstallAntiRevokeTriggers = async () => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||
if (sessionIds.length === 0) {
|
||||
showMessage('请先选择至少一个会话', false)
|
||||
return
|
||||
}
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeUninstalling(true)
|
||||
try {
|
||||
markAntiRevokeRowsLoading(sessionIds)
|
||||
const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '批量卸载失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = sessionIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.success ? false : next[sessionId]?.installed,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '卸载失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount })
|
||||
showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`批量卸载失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeUninstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'antiRevoke') return
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
} catch (e: any) {
|
||||
if (!canceled) {
|
||||
showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
type WxidKeys = {
|
||||
decryptKey: string
|
||||
imageXorKey: number | null
|
||||
@@ -1199,6 +1482,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>开机自启动</label>
|
||||
<span className="form-hint">
|
||||
{launchAtStartupSupported
|
||||
? '开启后,登录系统时会自动启动 WeFlow。'
|
||||
: launchAtStartupReason || '当前环境暂不支持开机自启动。'}
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">
|
||||
{isUpdatingLaunchAtStartup
|
||||
? '保存中...'
|
||||
: launchAtStartupSupported
|
||||
? (launchAtStartup ? '已开启' : '已关闭')
|
||||
: '当前不可用'}
|
||||
</span>
|
||||
<label className="switch" htmlFor="launch-at-startup-toggle">
|
||||
<input
|
||||
id="launch-at-startup-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={launchAtStartup}
|
||||
disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup}
|
||||
onChange={(e) => {
|
||||
void handleLaunchAtStartupChange(e.target.checked)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>关闭主窗口时</label>
|
||||
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||
@@ -1255,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
const renderNotificationTab = () => {
|
||||
const { sessions } = useChatStore.getState()
|
||||
|
||||
// 获取已过滤会话的信息
|
||||
const getSessionInfo = (username: string) => {
|
||||
const session = sessions.find(s => s.username === username)
|
||||
const session = chatSessions.find(s => s.username === username)
|
||||
return {
|
||||
displayName: session?.displayName || username,
|
||||
avatarUrl: session?.avatarUrl || ''
|
||||
@@ -1284,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
||||
const availableSessions = sessions.filter(s => {
|
||||
const availableSessions = chatSessions.filter(s => {
|
||||
if (notificationFilterList.includes(s.username)) return false
|
||||
if (filterSearchKeyword) {
|
||||
const keyword = filterSearchKeyword.toLowerCase()
|
||||
@@ -1500,6 +1814,199 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
const renderAntiRevokeTab = () => {
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((session) => {
|
||||
if (!keyword) return true
|
||||
const displayName = String(session.displayName || '').toLowerCase()
|
||||
const username = String(session.username || '').toLowerCase()
|
||||
return displayName.includes(keyword) || username.includes(keyword)
|
||||
})
|
||||
const filteredSessionIds = filteredSessions.map((session) => session.username)
|
||||
const selectedCount = antiRevokeSelectedIds.size
|
||||
const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length
|
||||
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
|
||||
const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling
|
||||
const statusStats = filteredSessions.reduce(
|
||||
(acc, session) => {
|
||||
const rowState = antiRevokeStatusMap[session.username]
|
||||
if (rowState?.error) acc.failed += 1
|
||||
else if (rowState?.installed === true) acc.installed += 1
|
||||
else if (rowState?.installed === false) acc.notInstalled += 1
|
||||
return acc
|
||||
},
|
||||
{ installed: 0, notInstalled: 0, failed: 0 }
|
||||
)
|
||||
|
||||
const toggleSelected = (sessionId: string) => {
|
||||
setAntiRevokeSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) next.delete(sessionId)
|
||||
else next.add(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
if (filteredSessionIds.length === 0) return
|
||||
setAntiRevokeSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const sessionId of filteredSessionIds) {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setAntiRevokeSelectedIds(new Set())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-content anti-revoke-tab">
|
||||
<div className="anti-revoke-hero">
|
||||
<div className="anti-revoke-hero-main">
|
||||
<h3>防撤回</h3>
|
||||
<p>你可以根据会话进行防撤回部署,安装后无需保持 WeFlow 运行即可实现防撤回</p>
|
||||
</div>
|
||||
<div className="anti-revoke-metrics">
|
||||
<div className="anti-revoke-metric is-total">
|
||||
<span className="label">筛选会话</span>
|
||||
<span className="value">{filteredSessionIds.length}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-installed">
|
||||
<span className="label">已安装</span>
|
||||
<span className="value">{statusStats.installed}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-pending">
|
||||
<span className="label">未安装</span>
|
||||
<span className="value">{statusStats.notInstalled}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-error">
|
||||
<span className="label">异常</span>
|
||||
<span className="value">{statusStats.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-control-card">
|
||||
<div className="anti-revoke-toolbar">
|
||||
<div className="filter-search-box anti-revoke-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索会话..."
|
||||
value={antiRevokeSearchKeyword}
|
||||
onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="anti-revoke-toolbar-actions">
|
||||
<div className="anti-revoke-btn-group">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}>
|
||||
<RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="anti-revoke-btn-group">
|
||||
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}>
|
||||
全选
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}>
|
||||
清空选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-batch-actions">
|
||||
<div className="anti-revoke-btn-group anti-revoke-batch-btns">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||
{isAntiRevokeInstalling ? '安装中...' : '批量安装'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||
{isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="anti-revoke-selected-count">
|
||||
<span>已选 <strong>{selectedCount}</strong> 个会话</span>
|
||||
<span>筛选命中 <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{antiRevokeSummary && (
|
||||
<div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}>
|
||||
{antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'}
|
||||
完成:成功 {antiRevokeSummary.success},失败 {antiRevokeSummary.failed}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="anti-revoke-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>会话({filteredSessions.length})</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{filteredSessions.map((session) => {
|
||||
const rowState = antiRevokeStatusMap[session.username]
|
||||
let statusClass = 'unknown'
|
||||
let statusLabel = '未检查'
|
||||
if (rowState?.loading) {
|
||||
statusClass = 'checking'
|
||||
statusLabel = '检查中'
|
||||
} else if (rowState?.error) {
|
||||
statusClass = 'error'
|
||||
statusLabel = '失败'
|
||||
} else if (rowState?.installed === true) {
|
||||
statusClass = 'installed'
|
||||
statusLabel = '已安装'
|
||||
} else if (rowState?.installed === false) {
|
||||
statusClass = 'not-installed'
|
||||
statusLabel = '未安装'
|
||||
}
|
||||
return (
|
||||
<div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}>
|
||||
<label className="anti-revoke-row-main">
|
||||
<span className="anti-revoke-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={antiRevokeSelectedIds.has(session.username)}
|
||||
onChange={() => toggleSelected(session.username)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="check-indicator" aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
</span>
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={30}
|
||||
/>
|
||||
<div className="anti-revoke-row-text">
|
||||
<span className="name">{session.displayName || session.username}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="anti-revoke-row-status">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
<i className="status-dot" aria-hidden="true" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
{rowState?.error && <span className="status-error">{rowState.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -2444,7 +2951,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="about-footer">
|
||||
<p className="about-desc">微信聊天记录分析工具</p>
|
||||
<div className="about-links">
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>官网</a>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://weflow.top') }}>官网</a>
|
||||
<span>·</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>GitHub 仓库</a>
|
||||
<span>·</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
|
||||
<span>·</span>
|
||||
@@ -2621,6 +3130,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'antiRevoke' && renderAntiRevokeTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
|
||||
+22
-4
@@ -13,6 +13,7 @@ export const CONFIG_KEYS = {
|
||||
LAST_SESSION: 'lastSession',
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
CACHE_PATH: 'cachePath',
|
||||
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
||||
|
||||
EXPORT_PATH: 'exportPath',
|
||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||
@@ -93,6 +94,7 @@ export interface ExportDefaultMediaConfig {
|
||||
videos: boolean
|
||||
voices: boolean
|
||||
emojis: boolean
|
||||
files: boolean
|
||||
}
|
||||
|
||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
@@ -103,7 +105,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
}
|
||||
|
||||
// 获取解密密钥
|
||||
@@ -258,6 +261,18 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||
}
|
||||
|
||||
// 获取开机自启动偏好
|
||||
export async function getLaunchAtStartup(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置开机自启动偏好
|
||||
export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
|
||||
}
|
||||
|
||||
// 获取 LLM 模型路径
|
||||
export async function getLlmModelPath(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||
@@ -410,7 +425,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
|
||||
images: value,
|
||||
videos: value,
|
||||
voices: value,
|
||||
emojis: value
|
||||
emojis: value,
|
||||
files: value
|
||||
}
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -419,7 +435,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
|
||||
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
|
||||
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
|
||||
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
|
||||
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
|
||||
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis,
|
||||
files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -431,7 +448,8 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr
|
||||
images: media.images,
|
||||
videos: media.videos,
|
||||
voices: media.voices,
|
||||
emojis: media.emojis
|
||||
emojis: media.emojis,
|
||||
files: media.files
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+78
-57
@@ -1,6 +1,46 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ChatSession, Message, Contact } from '../types/models'
|
||||
|
||||
const messageAliasIndex = new Set<string>()
|
||||
|
||||
function buildPrimaryMessageKey(message: Message): string {
|
||||
if (message.messageKey) return String(message.messageKey)
|
||||
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
||||
}
|
||||
|
||||
function buildMessageAliasKeys(message: Message): string[] {
|
||||
const keys = [buildPrimaryMessageKey(message)]
|
||||
const localId = Math.max(0, Number(message.localId || 0))
|
||||
const serverId = Math.max(0, Number(message.serverId || 0))
|
||||
const createTime = Math.max(0, Number(message.createTime || 0))
|
||||
const localType = Math.floor(Number(message.localType || 0))
|
||||
const sender = String(message.senderUsername || '')
|
||||
const isSend = Number(message.isSend ?? -1)
|
||||
|
||||
if (localId > 0) {
|
||||
keys.push(`lid:${localId}`)
|
||||
}
|
||||
if (serverId > 0) {
|
||||
keys.push(`sid:${serverId}`)
|
||||
}
|
||||
if (localType === 3) {
|
||||
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
||||
if (imageIdentity) {
|
||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
function rebuildMessageAliasIndex(messages: Message[]): void {
|
||||
messageAliasIndex.clear()
|
||||
for (const message of messages) {
|
||||
const aliasKeys = buildMessageAliasKeys(message)
|
||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
// 连接状态
|
||||
isConnected: boolean
|
||||
@@ -69,59 +109,37 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||
|
||||
setCurrentSession: (sessionId, options) => set((state) => ({
|
||||
currentSessionId: sessionId,
|
||||
messages: options?.preserveMessages ? state.messages : [],
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false
|
||||
})),
|
||||
setCurrentSession: (sessionId, options) => set((state) => {
|
||||
const nextMessages = options?.preserveMessages ? state.messages : []
|
||||
rebuildMessageAliasIndex(nextMessages)
|
||||
return {
|
||||
currentSessionId: sessionId,
|
||||
messages: nextMessages,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false
|
||||
}
|
||||
}),
|
||||
|
||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
setMessages: (messages) => set(() => {
|
||||
rebuildMessageAliasIndex(messages || [])
|
||||
return { messages }
|
||||
}),
|
||||
|
||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||
const buildPrimaryKey = (m: Message): string => {
|
||||
if (m.messageKey) return String(m.messageKey)
|
||||
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||
}
|
||||
const buildAliasKeys = (m: Message): string[] => {
|
||||
const keys = [buildPrimaryKey(m)]
|
||||
const localId = Math.max(0, Number(m.localId || 0))
|
||||
const serverId = Math.max(0, Number(m.serverId || 0))
|
||||
const createTime = Math.max(0, Number(m.createTime || 0))
|
||||
const localType = Math.floor(Number(m.localType || 0))
|
||||
const sender = String(m.senderUsername || '')
|
||||
const isSend = Number(m.isSend ?? -1)
|
||||
|
||||
if (localId > 0) {
|
||||
keys.push(`lid:${localId}`)
|
||||
}
|
||||
if (serverId > 0) {
|
||||
keys.push(`sid:${serverId}`)
|
||||
}
|
||||
if (localType === 3) {
|
||||
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
|
||||
if (imageIdentity) {
|
||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const currentMessages = state.messages || []
|
||||
const existingAliases = new Set<string>()
|
||||
currentMessages.forEach((msg) => {
|
||||
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
||||
})
|
||||
if (messageAliasIndex.size === 0 && currentMessages.length > 0) {
|
||||
rebuildMessageAliasIndex(currentMessages)
|
||||
}
|
||||
|
||||
const filtered: Message[] = []
|
||||
newMessages.forEach((msg) => {
|
||||
const aliasKeys = buildAliasKeys(msg)
|
||||
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
||||
const aliasKeys = buildMessageAliasKeys(msg)
|
||||
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
||||
if (exists) return
|
||||
filtered.push(msg)
|
||||
aliasKeys.forEach((key) => existingAliases.add(key))
|
||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return state
|
||||
@@ -150,20 +168,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||
|
||||
reset: () => set({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
sessions: [],
|
||||
filteredSessions: [],
|
||||
currentSessionId: null,
|
||||
isLoadingSessions: false,
|
||||
messages: [],
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: ''
|
||||
reset: () => set(() => {
|
||||
messageAliasIndex.clear()
|
||||
return {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
sessions: [],
|
||||
filteredSessions: [],
|
||||
currentSessionId: null,
|
||||
isLoadingSessions: false,
|
||||
messages: [],
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: ''
|
||||
}
|
||||
})
|
||||
}))
|
||||
Vendored
+31
-1
@@ -56,6 +56,14 @@ export interface ElectronAPI {
|
||||
app: {
|
||||
getDownloadsPath: () => Promise<string>
|
||||
getVersion: () => Promise<string>
|
||||
getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }>
|
||||
setLaunchAtStartup: (enabled: boolean) => Promise<{
|
||||
success: boolean
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
}>
|
||||
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
|
||||
downloadAndInstall: () => Promise<void>
|
||||
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
|
||||
@@ -218,6 +226,21 @@ export interface ElectronAPI {
|
||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
||||
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
|
||||
error?: string
|
||||
}>
|
||||
installAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||
error?: string
|
||||
}>
|
||||
uninstallAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
|
||||
error?: string
|
||||
}>
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||
getContacts: (options?: { lite?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
@@ -326,6 +349,11 @@ export interface ElectronAPI {
|
||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||
}
|
||||
biz: {
|
||||
listAccounts: (account?: string) => Promise<any[]>
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
@@ -868,7 +896,7 @@ export interface ElectronAPI {
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
@@ -878,6 +906,8 @@ export interface ExportOptions {
|
||||
exportVoices?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportFiles?: boolean
|
||||
maxFileSizeMb?: number
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface Message {
|
||||
fileName?: string // 文件名
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
fileMd5?: string // 文件 MD5
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
appMsgKind?: string // 归一化 appmsg 类型
|
||||
appMsgDesc?: string
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
export const drawPatternBackground = async (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
bgColor: string,
|
||||
isDark: boolean
|
||||
) => {
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
const pattern = ctx.createPattern(img, 'repeat')
|
||||
if (pattern) {
|
||||
ctx.fillStyle = pattern
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user