支持自动化条件导出;优化引导页面提示;支持快速添加账号
This commit is contained in:
1 parent
69a598f196
commit
86daa8ef06
19 files changed
+3765
-688
No files matched your search
@@ -0,0 +1,274 @@
|
||||
.account-management-page {
|
||||
padding: 22px 24px;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-management-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-management-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-notice {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-action {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.account-notice.success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #15803d;
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.account-empty {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 18px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.is-current {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&.current {
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.14);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #166534;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.meta-tip {
|
||||
margin-left: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.btn {
|
||||
min-width: 104px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-footer {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-management-page {
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.account-management-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import './AccountManagementPage.scss'
|
||||
|
||||
interface ScannedWxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface ManagedAccountItem {
|
||||
wxid: string
|
||||
normalizedWxid: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
modifiedTime?: number
|
||||
configUpdatedAt?: number
|
||||
hasConfig: boolean
|
||||
isCurrent: boolean
|
||||
fromScan: boolean
|
||||
}
|
||||
|
||||
type AccountProfileCacheEntry = {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface DeleteUndoState {
|
||||
targetWxid: string
|
||||
deletedConfigEntries: Array<[string, configService.WxidConfig]>
|
||||
deletedProfileEntries: Array<[string, AccountProfileCacheEntry]>
|
||||
previousCurrentWxid: string
|
||||
shouldRestoreAsCurrent: boolean
|
||||
previousDbConnected: boolean
|
||||
}
|
||||
|
||||
type NoticeState =
|
||||
| { type: 'success' | 'error' | 'info'; text: string }
|
||||
| null
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
const hiddenDeletedAccountIds = new Set<string>()
|
||||
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
const resolveAccountDisplayName = (
|
||||
candidates: Array<unknown>,
|
||||
wxidCandidates: Set<string>
|
||||
): string => {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue
|
||||
if (candidate.length === 0) continue
|
||||
const normalized = candidate.trim().toLowerCase()
|
||||
if (normalized.startsWith('wxid_')) continue
|
||||
if (normalized && wxidCandidates.has(normalized)) continue
|
||||
return candidate
|
||||
}
|
||||
return DEFAULT_ACCOUNT_DISPLAY_NAME
|
||||
}
|
||||
|
||||
const resolveAccountAvatarText = (displayName?: string): string => {
|
||||
if (typeof displayName !== 'string' || displayName.length === 0) return '微'
|
||||
const visible = displayName.trim()
|
||||
return (visible && [...visible][0]) || '微'
|
||||
}
|
||||
|
||||
const readAccountProfilesCache = (): Record<string, AccountProfileCacheEntry> => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, AccountProfileCacheEntry> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function AccountManagementPage() {
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const setDbConnected = useAppStore(state => state.setDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [currentWxid, setCurrentWxid] = useState('')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [workingWxid, setWorkingWxid] = useState('')
|
||||
const [notice, setNotice] = useState<NoticeState>(null)
|
||||
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(null)
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid(),
|
||||
configService.getWxidConfigs()
|
||||
])
|
||||
const nextDbPath = String(path || '').trim()
|
||||
const nextCurrentWxid = String(rawCurrentWxid || '').trim()
|
||||
const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid
|
||||
setDbPath(nextDbPath)
|
||||
setCurrentWxid(nextCurrentWxid)
|
||||
|
||||
let scannedWxids: ScannedWxidOption[] = []
|
||||
if (nextDbPath) {
|
||||
try {
|
||||
const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath)
|
||||
scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : []
|
||||
} catch {
|
||||
scannedWxids = []
|
||||
}
|
||||
}
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const configEntries = Object.entries(wxidConfigs || {})
|
||||
const configByNormalized = new Map<string, { key: string; value: configService.WxidConfig }>()
|
||||
for (const [wxid, cfg] of configEntries) {
|
||||
const normalized = normalizeAccountId(wxid) || wxid
|
||||
if (!normalized) continue
|
||||
const previous = configByNormalized.get(normalized)
|
||||
if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) {
|
||||
configByNormalized.set(normalized, { key: wxid, value: cfg || {} })
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Map<string, ManagedAccountItem>()
|
||||
for (const scanned of scannedWxids) {
|
||||
const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid
|
||||
if (!normalized) continue
|
||||
const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized]
|
||||
const matchedConfig = configByNormalized.get(normalized)
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(scanned.wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[scanned.nickname, cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid: scanned.wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: scanned.avatarUrl || cached?.avatarUrl,
|
||||
modifiedTime: Number(scanned.modifiedTime || 0),
|
||||
configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0),
|
||||
hasConfig: Boolean(matchedConfig),
|
||||
isCurrent: normalizedCurrent && normalized === normalizedCurrent,
|
||||
fromScan: true
|
||||
})
|
||||
}
|
||||
|
||||
for (const [normalized, matchedConfig] of configByNormalized.entries()) {
|
||||
if (merged.has(normalized)) continue
|
||||
const wxid = matchedConfig.key
|
||||
const cached = accountProfileCache[wxid] || accountProfileCache[normalized]
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: cached?.avatarUrl,
|
||||
modifiedTime: 0,
|
||||
configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0),
|
||||
hasConfig: true,
|
||||
isCurrent: normalizedCurrent && normalized === normalizedCurrent,
|
||||
fromScan: false
|
||||
})
|
||||
}
|
||||
|
||||
// 被“删除配置”操作移除的账号,在当前会话中从列表隐藏;
|
||||
// 若后续再次生成配置,则自动恢复展示。
|
||||
for (const [normalized, item] of Array.from(merged.entries())) {
|
||||
if (!hiddenDeletedAccountIds.has(normalized)) continue
|
||||
if (item.hasConfig) {
|
||||
hiddenDeletedAccountIds.delete(normalized)
|
||||
continue
|
||||
}
|
||||
merged.delete(normalized)
|
||||
}
|
||||
|
||||
const nextAccounts = Array.from(merged.values()).sort((a, b) => {
|
||||
if (a.isCurrent && !b.isCurrent) return -1
|
||||
if (!a.isCurrent && b.isCurrent) return 1
|
||||
const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0)
|
||||
if (scanDiff !== 0) return scanDiff
|
||||
return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0)
|
||||
})
|
||||
setAccounts(nextAccounts)
|
||||
} catch (error) {
|
||||
console.error('加载账号列表失败:', error)
|
||||
setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' })
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts()
|
||||
const onWxidChanged = () => { void loadAccounts() }
|
||||
const onWindowFocus = () => { void loadAccounts() }
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadAccounts()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [loadAccounts])
|
||||
|
||||
const clearRuntimeCacheState = useCallback(async () => {
|
||||
if (isDbConnected) {
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
}, [clearAnalyticsStoreCache, isDbConnected, resetChatStore])
|
||||
|
||||
const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => {
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setDecryptKey(wxidConfig?.decryptKey || '')
|
||||
await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0)
|
||||
await configService.setImageAesKey(wxidConfig?.imageAesKey || '')
|
||||
}, [])
|
||||
|
||||
const handleSwitchAccount = useCallback(async (wxid: string) => {
|
||||
if (!wxid || workingWxid) return
|
||||
const targetNormalized = normalizeAccountId(wxid) || wxid
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return
|
||||
|
||||
setWorkingWxid(wxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const configEntries = Object.entries(allConfigs || {})
|
||||
const matched = configEntries.find(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === wxid || normalized === targetNormalized
|
||||
})
|
||||
const targetConfig = matched?.[1] || null
|
||||
await applyWxidConfig(wxid, targetConfig)
|
||||
await clearRuntimeCacheState()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } }))
|
||||
setNotice({ type: 'success', text: `已切换到账号「${wxid}」` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('切换账号失败:', error)
|
||||
setNotice({ type: 'error', text: '切换账号失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid])
|
||||
|
||||
const handleAddAccount = useCallback(async () => {
|
||||
if (workingWxid) return
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' })
|
||||
await loadAccounts()
|
||||
const latestWxid = String(await configService.getMyWxid() || '').trim()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } }))
|
||||
} catch (error) {
|
||||
console.error('打开添加账号引导失败:', error)
|
||||
setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' })
|
||||
}
|
||||
}, [loadAccounts, workingWxid])
|
||||
|
||||
const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => {
|
||||
if (!targetWxid || workingWxid) return
|
||||
|
||||
const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid
|
||||
|
||||
setWorkingWxid(targetWxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const nextConfigs: Record<string, configService.WxidConfig> = { ...allConfigs }
|
||||
const matchedKeys = Object.keys(nextConfigs).filter((key) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === targetWxid || normalized === normalizedTarget
|
||||
})
|
||||
|
||||
if (matchedKeys.length === 0) {
|
||||
setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` })
|
||||
return
|
||||
}
|
||||
|
||||
const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}])
|
||||
for (const key of matchedKeys) {
|
||||
delete nextConfigs[key]
|
||||
}
|
||||
await configService.setWxidConfigs(nextConfigs)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = []
|
||||
for (const key of Object.keys(accountProfileCache)) {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
if (key === targetWxid || normalized === normalizedTarget) {
|
||||
deletedProfileEntries.push([key, accountProfileCache[key]])
|
||||
delete accountProfileCache[key]
|
||||
}
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget)
|
||||
const undoPayload: DeleteUndoState = {
|
||||
targetWxid,
|
||||
deletedConfigEntries,
|
||||
deletedProfileEntries,
|
||||
previousCurrentWxid: currentWxid,
|
||||
shouldRestoreAsCurrent: isDeletingCurrent,
|
||||
previousDbConnected: isDbConnected
|
||||
}
|
||||
|
||||
if (isDeletingCurrent) {
|
||||
await clearRuntimeCacheState()
|
||||
|
||||
const remainingEntries = Object.entries(nextConfigs)
|
||||
.filter(([wxid]) => Boolean(String(wxid || '').trim()))
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
|
||||
|
||||
if (remainingEntries.length > 0) {
|
||||
const [nextWxid, nextConfig] = remainingEntries[0]
|
||||
await applyWxidConfig(nextWxid, nextConfig || null)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
await configService.setMyWxid('')
|
||||
await configService.setDecryptKey('')
|
||||
await configService.setImageXorKey(0)
|
||||
await configService.setImageAesKey('')
|
||||
setDbConnected(false)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('删除账号配置失败:', error)
|
||||
setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const handleUndoDelete = useCallback(async () => {
|
||||
if (!deleteUndoState || workingWxid) return
|
||||
|
||||
setWorkingWxid(`undo:${deleteUndoState.targetWxid}`)
|
||||
setNotice(null)
|
||||
try {
|
||||
const currentConfigs = await configService.getWxidConfigs()
|
||||
const restoredConfigs: Record<string, configService.WxidConfig> = { ...currentConfigs }
|
||||
for (const [key, configValue] of deleteUndoState.deletedConfigEntries) {
|
||||
restoredConfigs[key] = configValue || {}
|
||||
}
|
||||
await configService.setWxidConfigs(restoredConfigs)
|
||||
hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
|
||||
accountProfileCache[key] = profile
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) {
|
||||
const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid
|
||||
const restoreConfigEntry = Object.entries(restoredConfigs)
|
||||
.filter(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized
|
||||
})
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0]
|
||||
const restoreConfig = restoreConfigEntry?.[1] || null
|
||||
|
||||
await clearRuntimeCacheState()
|
||||
await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig)
|
||||
if (deleteUndoState.previousDbConnected) {
|
||||
setDbConnected(true, dbPath || undefined)
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } }))
|
||||
}
|
||||
|
||||
setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` })
|
||||
setDeleteUndoState(null)
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('撤回删除失败:', error)
|
||||
setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const currentAccountLabel = useMemo(() => {
|
||||
if (!currentWxid) return '未设置'
|
||||
return currentWxid
|
||||
}, [currentWxid])
|
||||
|
||||
const formatTime = (value?: number): string => {
|
||||
const ts = Number(value || 0)
|
||||
if (!ts) return '未知'
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-management-page">
|
||||
<header className="account-management-header">
|
||||
<div>
|
||||
<h2>账号管理</h2>
|
||||
<p>统一管理切换账号、添加账号、删除账号配置。</p>
|
||||
</div>
|
||||
<div className="account-management-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
|
||||
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
|
||||
<UserPlus size={16} /> 添加账号
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="account-management-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">数据库目录</span>
|
||||
<span className="summary-value">{dbPath || '未配置'}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">当前账号</span>
|
||||
<span className="summary-value">{currentAccountLabel}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">账号数量</span>
|
||||
<span className="summary-value">{accounts.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice && (
|
||||
<div className={`account-notice ${notice.type}`}>
|
||||
<span>{notice.text}</span>
|
||||
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
|
||||
<button
|
||||
type="button"
|
||||
className="notice-action"
|
||||
onClick={() => void handleUndoDelete()}
|
||||
disabled={Boolean(workingWxid)}
|
||||
>
|
||||
撤回
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<div className="account-empty">
|
||||
<Database size={20} />
|
||||
<span>未发现可管理账号,请先添加账号或检查数据库目录。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="account-list">
|
||||
{accounts.map((account) => (
|
||||
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
|
||||
<div className="account-avatar">
|
||||
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
|
||||
</div>
|
||||
<div className="account-main">
|
||||
<div className="account-title-row">
|
||||
<h3>{account.displayName}</h3>
|
||||
{account.isCurrent && (
|
||||
<span className="account-badge current">
|
||||
<CheckCircle2 size={12} /> 当前
|
||||
</span>
|
||||
)}
|
||||
{account.hasConfig ? (
|
||||
<span className="account-badge ok">已保存配置</span>
|
||||
) : (
|
||||
<span className="account-badge warn">未保存配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">wxid: {account.wxid}</div>
|
||||
<div className="account-meta">
|
||||
最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)}
|
||||
{!account.fromScan && <span className="meta-tip">(仅配置记录)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void handleSwitchAccount(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
|
||||
>
|
||||
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void handleDeleteAccountConfig(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || !account.hasConfig}
|
||||
>
|
||||
<Trash2 size={14} /> 删除配置
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="account-management-footer">
|
||||
删除仅影响 WeFlow 本地配置,不会删除微信原始数据文件。
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountManagementPage
|
||||
+801
-123
@@ -3,10 +3,8 @@
|
||||
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);
|
||||
background: var(--bg-primary);
|
||||
/* Minimal background matching Footprint */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -38,7 +36,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
animation: exportSectionReveal 0.38s ease both;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,9 @@
|
||||
}
|
||||
|
||||
@keyframes sessionLoadDetailBars {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.72);
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -460,7 +462,7 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
> span {
|
||||
>span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1308,12 +1310,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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; }
|
||||
.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);
|
||||
@@ -1716,19 +1735,20 @@
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
max-width: max-content;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.tab-btn-content {
|
||||
display: inline-flex;
|
||||
@@ -1753,21 +1773,18 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
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);
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
|
||||
.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));
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1812,15 +1829,17 @@
|
||||
gap: 6px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
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;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 4%, transparent);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) inset;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
transition: all 0.2s 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);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent), 0 1px 3px rgba(0, 0, 0, 0.02) inset;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -1829,13 +1848,14 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
border: 1px solid transparent;
|
||||
background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary));
|
||||
color: var(--text-tertiary);
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1843,12 +1863,11 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
transition: all 0.2s 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));
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1892,25 +1911,21 @@
|
||||
--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;
|
||||
--contacts-actions-sticky-width: 240px;
|
||||
--contacts-table-min-width: 1240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
min-height: 320px;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
background: var(--bg-secondary);
|
||||
transition: all 0.2s 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);
|
||||
background: color-mix(in srgb, var(--text-tertiary) 2%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2031,11 +2046,12 @@
|
||||
}
|
||||
|
||||
.issue-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2045,14 +2061,17 @@
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2070,20 +2089,20 @@
|
||||
}
|
||||
|
||||
.contacts-list-header {
|
||||
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--contacts-column-gap);
|
||||
padding: 10px var(--contacts-inline-padding) 8px;
|
||||
min-width: max(100%, var(--contacts-table-min-width));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
background: var(--contacts-header-bg);
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
flex-shrink: 0;
|
||||
backdrop-filter: saturate(115%) blur(3px);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&.is-draggable {
|
||||
cursor: grab;
|
||||
@@ -2164,7 +2183,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
@@ -2248,25 +2267,25 @@
|
||||
}
|
||||
|
||||
.selection-clear-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
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;
|
||||
transition: all 0.2s 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));
|
||||
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -2275,19 +2294,21 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
background: color-mix(in srgb, var(--primary) 85%, #fff);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
@@ -2360,7 +2381,7 @@
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
--contacts-row-bg: var(--bg-secondary);
|
||||
--contacts-row-bg: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--contacts-column-gap);
|
||||
@@ -2369,15 +2390,14 @@
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
background: var(--contacts-row-bg);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
box-shadow: none;
|
||||
|
||||
&: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);
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2634,32 +2654,34 @@
|
||||
min-width: 1300px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--bg-secondary);
|
||||
background: transparent;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 4;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
padding: 10px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 4%, transparent);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(var(--primary-rgb), 0.03);
|
||||
tbody tr:hover td {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
}
|
||||
|
||||
.selected-row,
|
||||
@@ -2797,27 +2819,26 @@
|
||||
}
|
||||
|
||||
.row-detail-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 7px 12px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
background: color-mix(in srgb, var(--text-tertiary) 14%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2883,7 +2904,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-export-link.state-running + .row-export-time {
|
||||
.row-export-link.state-running+.row-export-time {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -2918,23 +2939,22 @@
|
||||
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%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24);
|
||||
box-shadow: -18px 24px 60px rgba(0, 0, 0, 0.16);
|
||||
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: 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));
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
background: transparent;
|
||||
|
||||
.detail-header-main {
|
||||
display: flex;
|
||||
@@ -3318,22 +3338,22 @@
|
||||
.export-session-sns-dialog {
|
||||
width: min(760px, 100%);
|
||||
max-height: min(86vh, 860px);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
animation: footprintFadeSlideUp 0.3s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
|
||||
.sns-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
}
|
||||
|
||||
.sns-dialog-header-main {
|
||||
@@ -3673,12 +3693,10 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.08) 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: exportSkeletonShimmer 1.2s linear infinite;
|
||||
}
|
||||
@@ -3700,13 +3718,39 @@
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-12 { width: 48%; min-width: 42px; }
|
||||
.skeleton-line.w-20 { width: 22%; min-width: 36px; }
|
||||
.skeleton-line.w-30 { width: 32%; min-width: 120px; }
|
||||
.skeleton-line.w-40 { width: 45%; min-width: 80px; }
|
||||
.skeleton-line.w-60 { width: 62%; min-width: 110px; }
|
||||
.skeleton-line.w-100 { width: 100%; }
|
||||
.skeleton-line.h-32 { height: 32px; border-radius: 10px; }
|
||||
.skeleton-line.w-12 {
|
||||
width: 48%;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-20 {
|
||||
width: 22%;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-30 {
|
||||
width: 32%;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-40 {
|
||||
width: 45%;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-60 {
|
||||
width: 62%;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.skeleton-line.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line.h-32 {
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.export-dialog-overlay {
|
||||
position: fixed;
|
||||
@@ -4440,11 +4484,9 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
var(--card-bg) 38%
|
||||
);
|
||||
background: linear-gradient(180deg,
|
||||
transparent,
|
||||
var(--card-bg) 38%);
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
@@ -4824,6 +4866,7 @@
|
||||
0% {
|
||||
background-position: 220% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -20% 0;
|
||||
}
|
||||
@@ -4833,6 +4876,7 @@
|
||||
0% {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 1.8em;
|
||||
}
|
||||
@@ -4843,10 +4887,12 @@
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 6px rgba(255, 77, 79, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
|
||||
@@ -4854,6 +4900,7 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.export-board-page,
|
||||
.export-top-panel,
|
||||
.export-section-title-row,
|
||||
@@ -5207,3 +5254,634 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.automation-hint-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.automation-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 7750;
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.automation-modal {
|
||||
width: min(680px, 100%);
|
||||
max-height: min(80vh, 820px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.automation-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.automation-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.automation-task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.automation-task-card {
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 5%, transparent);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 9%, transparent);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-task-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-task-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-task-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
&.enabled {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.running {
|
||||
background: rgba(82, 196, 26, 0.14);
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.queued {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-task-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.automation-editor-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 7800;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
animation: exportOverlayFadeIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.automation-editor-modal {
|
||||
width: min(560px, 100%);
|
||||
max-height: min(88vh, 900px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both;
|
||||
}
|
||||
|
||||
.automation-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
/* 裸 input 统一样式(未套 .automation-form-field 的情况) */
|
||||
>input[type='datetime-local'],
|
||||
>input[type='number'],
|
||||
>input[type='text'] {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* datetime-local 的日历图标调整外观 */
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
filter: var(--datetime-picker-icon-filter, none);
|
||||
transition: opacity 0.18s ease;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-fields-wrapper {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-text {
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-year-field,
|
||||
&::-webkit-datetime-edit-month-field,
|
||||
&::-webkit-datetime-edit-day-field,
|
||||
&::-webkit-datetime-edit-hour-field,
|
||||
&::-webkit-datetime-edit-minute-field,
|
||||
&::-webkit-datetime-edit-ampm-field {
|
||||
color: var(--text-primary);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 嵌套在 div 内的裸 input(如 stopAt 在 .automation-form-field > div 里) */
|
||||
input[type='datetime-local']:not(.automation-form-field input) {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.18s ease;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-year-field,
|
||||
&::-webkit-datetime-edit-month-field,
|
||||
&::-webkit-datetime-edit-day-field,
|
||||
&::-webkit-datetime-edit-hour-field,
|
||||
&::-webkit-datetime-edit-minute-field {
|
||||
color: var(--text-primary);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type='number']:not(.automation-form-field input) {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.automation-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.automation-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
>span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
input[type='datetime-local'] {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.automation-inline-time {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.automation-inline-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input[type='checkbox'] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-tertiary) 30%, transparent);
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 7px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.automation-segment-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.automation-segment-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 14%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-path-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.automation-draft-summary {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 5%, transparent);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.close-icon-btn {
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-secondary);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
padding: 8px 18px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 85%, #fff);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 终止时间选择器 */
|
||||
.automation-stopat-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-stopat-date {
|
||||
flex: 1.4;
|
||||
}
|
||||
|
||||
.automation-stopat-time {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自动化创建模式提示 */
|
||||
.automation-create-mode-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
animation: footprintFadeSlideUp 0.3s ease both;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
+1393
-76
@@ -32,6 +32,12 @@ import {
|
||||
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
|
||||
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
|
||||
import type { BackgroundTaskRecord } from '../types/backgroundTask'
|
||||
import type {
|
||||
ExportAutomationCondition,
|
||||
ExportAutomationDateRangeConfig,
|
||||
ExportAutomationSchedule,
|
||||
ExportAutomationTask
|
||||
} from '../types/exportAutomation'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
emitExportSessionStatus,
|
||||
@@ -55,12 +61,15 @@ import type { SnsPost } from '../types/sns'
|
||||
import {
|
||||
cloneExportDateRange,
|
||||
cloneExportDateRangeSelection,
|
||||
createDateRangeByLastNDays,
|
||||
createDefaultDateRange,
|
||||
createDefaultExportDateRangeSelection,
|
||||
getExportDateRangeLabel,
|
||||
resolveExportDateRangeConfig,
|
||||
serializeExportDateRangeConfig,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
type ExportDateRangePreset,
|
||||
type ExportDateRangeSelection
|
||||
} from '../utils/exportDateRange'
|
||||
import './ExportPage.scss'
|
||||
@@ -147,6 +156,8 @@ interface ExportTaskPayload {
|
||||
outputDir: string
|
||||
options?: ElectronExportOptions
|
||||
scope: TaskScope
|
||||
source: 'manual' | 'automation'
|
||||
automationTaskId?: string
|
||||
contentType?: ContentType
|
||||
sessionNames: string[]
|
||||
snsOptions?: {
|
||||
@@ -175,6 +186,7 @@ interface ExportTask {
|
||||
|
||||
interface ExportDialogState {
|
||||
open: boolean
|
||||
intent: 'manual' | 'automation-create'
|
||||
scope: TaskScope
|
||||
contentType?: ContentType
|
||||
sessionIds: string[]
|
||||
@@ -182,6 +194,27 @@ interface ExportDialogState {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AutomationTaskDraft {
|
||||
mode: 'create' | 'edit'
|
||||
id?: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
sessionIds: string[]
|
||||
sessionNames: string[]
|
||||
outputDir: string
|
||||
useGlobalOutputDir: boolean
|
||||
scope: Exclude<TaskScope, 'sns'>
|
||||
contentType?: ContentType
|
||||
optionTemplate: Omit<ElectronExportOptions, 'dateRange'>
|
||||
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
stopAtEnabled: boolean
|
||||
stopAtValue: string
|
||||
maxRunsEnabled: boolean
|
||||
maxRuns: number
|
||||
}
|
||||
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
||||
@@ -589,6 +622,7 @@ const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean
|
||||
}
|
||||
|
||||
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
||||
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
||||
@@ -599,6 +633,220 @@ const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
|
||||
type SessionDataSource = 'cache' | 'network' | null
|
||||
type ContactsDataSource = 'cache' | 'network' | null
|
||||
|
||||
const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0))
|
||||
const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
|
||||
|
||||
const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
const totalHours = (days * 24) + hours
|
||||
if (totalHours <= 0) return 0
|
||||
return totalHours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days} 天`)
|
||||
if (hours > 0) parts.push(`${hours} 小时`)
|
||||
return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次`
|
||||
}
|
||||
|
||||
const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const nowMs = now.getTime()
|
||||
const anchorAt = Math.max(
|
||||
0,
|
||||
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
|
||||
)
|
||||
if (nowMs < anchorAt + intervalMs) return null
|
||||
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}`
|
||||
}
|
||||
|
||||
const toDateTimeLocalValue = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hours = `${date.getHours()}`.padStart(2, '0')
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const parseDateTimeLocalValue = (value: string): number | null => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return null
|
||||
const parsed = new Date(text)
|
||||
const timestamp = parsed.getTime()
|
||||
if (!Number.isFinite(timestamp)) return null
|
||||
return Math.floor(timestamp)
|
||||
}
|
||||
|
||||
type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom'
|
||||
|
||||
const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [
|
||||
{ mode: 'all', label: '全部时间' },
|
||||
{ mode: 'yesterday', label: '往前1天' },
|
||||
{ mode: 'last7days', label: '往前7天' },
|
||||
{ mode: 'last30days', label: '往前30天' },
|
||||
{ mode: 'last1year', label: '往前1年' },
|
||||
{ mode: 'lastNDays', label: '往前N天' },
|
||||
{ mode: 'custom', label: '完整时间' }
|
||||
]
|
||||
|
||||
const AUTOMATION_LAST_N_DAYS_MIN = 1
|
||||
const AUTOMATION_LAST_N_DAYS_MAX = 3650
|
||||
const AUTOMATION_LAST_N_DAYS_DEFAULT = 3
|
||||
|
||||
const normalizeAutomationLastNDays = (value: unknown): number => {
|
||||
const parsed = Math.floor(Number(value) || 0)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT
|
||||
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed))
|
||||
}
|
||||
|
||||
const readAutomationLastNDays = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined
|
||||
): number | null => {
|
||||
if (!config || typeof config !== 'object') return null
|
||||
const raw = config as Record<string, unknown>
|
||||
const mode = String(raw.relativeMode || '').trim()
|
||||
if (mode !== 'last-n-days') return null
|
||||
const days = Math.floor(Number(raw.relativeDays) || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) return null
|
||||
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days))
|
||||
}
|
||||
|
||||
const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({
|
||||
version: 1,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
relativeMode: 'last-n-days',
|
||||
relativeDays: normalizeAutomationLastNDays(days)
|
||||
})
|
||||
|
||||
const resolveAutomationDateRangeSelection = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
now = new Date()
|
||||
): ExportDateRangeSelection => {
|
||||
const relativeDays = readAutomationLastNDays(config)
|
||||
if (relativeDays) {
|
||||
return {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByLastNDays(relativeDays, now)
|
||||
}
|
||||
}
|
||||
return resolveExportDateRangeConfig(config as any, now)
|
||||
}
|
||||
|
||||
const resolveAutomationRangeMode = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
selection: ExportDateRangeSelection
|
||||
): AutomationRangeMode => {
|
||||
if (readAutomationLastNDays(config)) return 'lastNDays'
|
||||
if (selection.useAllTime) return 'all'
|
||||
if (selection.preset === 'today') return 'today'
|
||||
if (selection.preset === 'yesterday') return 'yesterday'
|
||||
if (selection.preset === 'last7days') return 'last7days'
|
||||
if (selection.preset === 'last30days') return 'last30days'
|
||||
if (selection.preset === 'last1year') return 'last1year'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
const createAutomationSelectionByMode = (
|
||||
mode: Exclude<AutomationRangeMode, 'custom' | 'lastNDays'>,
|
||||
now = new Date()
|
||||
): ExportDateRangeSelection => {
|
||||
const preset: ExportDateRangePreset = mode
|
||||
return resolveExportDateRangeConfig({
|
||||
version: 1,
|
||||
preset,
|
||||
useAllTime: mode === 'all'
|
||||
}, now)
|
||||
}
|
||||
|
||||
const formatAutomationRangeLabel = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
selection?: ExportDateRangeSelection
|
||||
): string => {
|
||||
const resolved = selection || resolveAutomationDateRangeSelection(config, new Date())
|
||||
const mode = resolveAutomationRangeMode(config, resolved)
|
||||
if (mode === 'all') return '每次触发导出全部历史消息'
|
||||
if (mode === 'today') return '每次触发导出当天'
|
||||
if (mode === 'yesterday') return '每次触发导出前1天(昨日)'
|
||||
if (mode === 'last7days') return '每次触发导出前7天'
|
||||
if (mode === 'last30days') return '每次触发导出前30天'
|
||||
if (mode === 'last1year') return '每次触发导出前1年'
|
||||
if (mode === 'lastNDays') {
|
||||
return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天`
|
||||
}
|
||||
return `完整时间:${getExportDateRangeLabel(resolved)}`
|
||||
}
|
||||
|
||||
const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
|
||||
const endAt = Number(task.stopCondition?.endAt || 0)
|
||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||
const labels: string[] = []
|
||||
if (endAt > 0) {
|
||||
labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`)
|
||||
}
|
||||
if (maxRuns > 0) {
|
||||
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
|
||||
labels.push(`成功 ${successCount}/${maxRuns} 次后停止`)
|
||||
}
|
||||
return labels.length > 0 ? labels.join(' · ') : '无'
|
||||
}
|
||||
|
||||
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
|
||||
if (!anchorAt) return null
|
||||
return anchorAt + intervalMs
|
||||
}
|
||||
|
||||
const formatAutomationCurrentState = (
|
||||
task: ExportAutomationTask,
|
||||
queueState: 'queued' | 'running' | null,
|
||||
nowMs: number
|
||||
): string => {
|
||||
if (!task.enabled) return '已停用'
|
||||
if (queueState === 'running') return '执行中'
|
||||
if (queueState === 'queued') return '排队中'
|
||||
const nextTriggerAt = resolveAutomationNextTriggerAt(task)
|
||||
if (!nextTriggerAt) return '等待触发'
|
||||
const diff = nextTriggerAt - nowMs
|
||||
if (diff <= 0) return '即将触发'
|
||||
return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)`
|
||||
}
|
||||
|
||||
const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => {
|
||||
const status = task.runState?.lastRunStatus || 'idle'
|
||||
const label = (
|
||||
status === 'idle' ? '尚未执行' :
|
||||
status === 'queued' ? '已入队' :
|
||||
status === 'running' ? '执行中' :
|
||||
status === 'success' ? '执行成功' :
|
||||
status === 'error' ? '执行失败' :
|
||||
status === 'skipped' ? '已跳过' :
|
||||
status
|
||||
)
|
||||
const parts: string[] = [label]
|
||||
if (task.runState?.lastSuccessAt) {
|
||||
parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`)
|
||||
}
|
||||
if (task.runState?.lastSkipReason) {
|
||||
parts.push(task.runState.lastSkipReason)
|
||||
}
|
||||
if (task.runState?.lastError) {
|
||||
parts.push(task.runState.lastError)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
interface ContactsLoadSession {
|
||||
requestId: string
|
||||
startedAt: number
|
||||
@@ -1574,6 +1822,8 @@ function ExportPage() {
|
||||
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
|
||||
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
|
||||
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false)
|
||||
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
|
||||
const [automationHint, setAutomationHint] = useState<string | null>(null)
|
||||
const [expandedPerfTaskId, setExpandedPerfTaskId] = useState<string | null>(null)
|
||||
const [sessions, setSessions] = useState<SessionRow[]>([])
|
||||
const [sessionDataSource, setSessionDataSource] = useState<SessionDataSource>(null)
|
||||
@@ -1680,14 +1930,22 @@ function ExportPage() {
|
||||
|
||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||
open: false,
|
||||
intent: 'manual',
|
||||
scope: 'single',
|
||||
sessionIds: [],
|
||||
sessionNames: [],
|
||||
title: ''
|
||||
})
|
||||
const [isAutomationCreateMode, setIsAutomationCreateMode] = useState(false)
|
||||
const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false)
|
||||
|
||||
const [tasks, setTasks] = useState<ExportTask[]>([])
|
||||
const [automationTasks, setAutomationTasks] = useState<ExportAutomationTask[]>([])
|
||||
const [automationTaskDraft, setAutomationTaskDraft] = useState<AutomationTaskDraft | null>(null)
|
||||
const [isAutomationRangeDialogOpen, setIsAutomationRangeDialogOpen] = useState(false)
|
||||
const [isResolvingAutomationRangeBounds, setIsResolvingAutomationRangeBounds] = useState(false)
|
||||
const [automationRangeBounds, setAutomationRangeBounds] = useState<TimeRangeBounds | null>(null)
|
||||
const [automationRangeSelection, setAutomationRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
|
||||
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
|
||||
const [exportRecordsBySession, setExportRecordsBySession] = useState<Record<string, configService.ExportSessionRecordEntry[]>>({})
|
||||
@@ -1714,6 +1972,10 @@ function ExportPage() {
|
||||
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const runningTaskIdRef = useRef<string | null>(null)
|
||||
const tasksRef = useRef<ExportTask[]>([])
|
||||
const automationTasksRef = useRef<ExportAutomationTask[]>([])
|
||||
const automationTasksReadyRef = useRef(false)
|
||||
const automationSchedulerRunningRef = useRef(false)
|
||||
const automationQueueStatusByTaskIdRef = useRef<Map<string, TaskStatus>>(new Map())
|
||||
const hasSeededSnsStatsRef = useRef(false)
|
||||
const sessionLoadTokenRef = useRef(0)
|
||||
const preselectAppliedRef = useRef(false)
|
||||
@@ -1810,6 +2072,46 @@ function ExportPage() {
|
||||
return scopeKey
|
||||
}, [])
|
||||
|
||||
const persistAutomationTasks = useCallback(async (nextTasks: ExportAutomationTask[]) => {
|
||||
if (!automationTasksReadyRef.current) return
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
await configService.setExportAutomationTasks(scopeKey, nextTasks)
|
||||
}, [ensureExportCacheScope])
|
||||
|
||||
const updateAutomationTasks = useCallback((
|
||||
updater: (prev: ExportAutomationTask[]) => ExportAutomationTask[]
|
||||
) => {
|
||||
setAutomationTasks((prev) => {
|
||||
const next = updater(prev)
|
||||
void persistAutomationTasks(next)
|
||||
return next
|
||||
})
|
||||
}, [persistAutomationTasks])
|
||||
|
||||
const patchAutomationTask = useCallback((
|
||||
taskId: string,
|
||||
updater: (task: ExportAutomationTask) => ExportAutomationTask
|
||||
) => {
|
||||
updateAutomationTasks((prev) => prev.map((task) => (task.id === taskId ? updater(task) : task)))
|
||||
}, [updateAutomationTasks])
|
||||
|
||||
const markAutomationTaskSkipped = useCallback((taskId: string, reason: string, scheduleKey?: string) => {
|
||||
const now = Date.now()
|
||||
patchAutomationTask(taskId, (task) => ({
|
||||
...task,
|
||||
updatedAt: now,
|
||||
runState: {
|
||||
...(task.runState || {}),
|
||||
lastRunStatus: 'skipped',
|
||||
lastTriggeredAt: now,
|
||||
lastSkipAt: now,
|
||||
lastSkipReason: reason,
|
||||
lastError: undefined,
|
||||
lastScheduleKey: scheduleKey || task.runState?.lastScheduleKey
|
||||
}
|
||||
}))
|
||||
}, [patchAutomationTask])
|
||||
|
||||
const loadContactsCaches = useCallback(async (scopeKey: string) => {
|
||||
const [contactsItem, avatarItem] = await Promise.all([
|
||||
configService.getContactsListCache(scopeKey),
|
||||
@@ -1821,6 +2123,22 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensureAutomationTasksLoaded = useCallback(async () => {
|
||||
if (automationTasksReadyRef.current) return
|
||||
try {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
const automationTaskItem = await configService.getExportAutomationTasks(scopeKey)
|
||||
setAutomationTasks(automationTaskItem?.tasks || [])
|
||||
automationTasksReadyRef.current = true
|
||||
} catch (error) {
|
||||
console.error('加载自动化导出任务失败:', error)
|
||||
}
|
||||
}, [ensureExportCacheScope])
|
||||
|
||||
useEffect(() => {
|
||||
void ensureAutomationTasksLoaded()
|
||||
}, [ensureAutomationTasksLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -2234,6 +2552,10 @@ function ExportPage() {
|
||||
tasksRef.current = tasks
|
||||
}, [tasks])
|
||||
|
||||
useEffect(() => {
|
||||
automationTasksRef.current = automationTasks
|
||||
}, [automationTasks])
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
@@ -2288,8 +2610,16 @@ function ExportPage() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [isTaskCenterOpen, expandedPerfTaskId, tasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAutomationModalOpen) return
|
||||
setNowTick(Date.now())
|
||||
const timer = window.setInterval(() => setNowTick(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [isAutomationModalOpen])
|
||||
|
||||
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
|
||||
setIsBaseConfigLoading(true)
|
||||
automationTasksReadyRef.current = false
|
||||
let isReady = true
|
||||
try {
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
@@ -2314,6 +2644,7 @@ function ExportPage() {
|
||||
])
|
||||
|
||||
const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope)
|
||||
const automationTaskItem = await configService.getExportAutomationTasks(exportCacheScope)
|
||||
|
||||
if (savedPath) {
|
||||
setExportFolder(savedPath)
|
||||
@@ -2342,6 +2673,8 @@ function ExportPage() {
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setAutomationTasks(automationTaskItem?.tasks || [])
|
||||
automationTasksReadyRef.current = true
|
||||
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||
@@ -2381,6 +2714,7 @@ function ExportPage() {
|
||||
}))
|
||||
} catch (error) {
|
||||
isReady = false
|
||||
automationTasksReadyRef.current = false
|
||||
console.error('加载导出配置失败:', error)
|
||||
} finally {
|
||||
setIsBaseConfigLoading(false)
|
||||
@@ -4046,6 +4380,7 @@ function ExportPage() {
|
||||
useEffect(() => {
|
||||
if (isExportRoute) return
|
||||
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
|
||||
setIsAutomationCreateMode(false)
|
||||
sessionLoadTokenRef.current = Date.now()
|
||||
sessionCountRequestIdRef.current += 1
|
||||
snsUserPostCountsHydrationTokenRef.current += 1
|
||||
@@ -4126,8 +4461,8 @@ function ExportPage() {
|
||||
|
||||
const clearSelection = () => setSelectedSessions(new Set())
|
||||
|
||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
||||
setExportDialog({ open: true, ...payload })
|
||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
|
||||
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeBounds(null)
|
||||
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||
@@ -4197,7 +4532,7 @@ function ExportPage() {
|
||||
])
|
||||
|
||||
const closeExportDialog = useCallback(() => {
|
||||
setExportDialog(prev => ({ ...prev, open: false }))
|
||||
setExportDialog(prev => ({ ...prev, open: false, intent: 'manual' }))
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeBounds(null)
|
||||
}, [])
|
||||
@@ -4488,6 +4823,202 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateAutomationDraft = useCallback(() => {
|
||||
setIsAutomationModalOpen(false)
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
setIsAutomationCreateMode(true)
|
||||
setSelectedSessions(new Set())
|
||||
setAutomationHint('已进入自动化任务创建:请勾选联系人,然后点击「加入任务」')
|
||||
}, [])
|
||||
|
||||
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
|
||||
const schedule = task.schedule
|
||||
const stopAt = Number(task.stopCondition?.endAt || 0)
|
||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
||||
setAutomationRangeSelection(resolvedRange)
|
||||
setAutomationRangeBounds(null)
|
||||
setAutomationTaskDraft({
|
||||
mode: 'edit',
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
enabled: task.enabled,
|
||||
sessionIds: task.sessionIds,
|
||||
sessionNames: task.sessionNames,
|
||||
outputDir: task.outputDir || exportFolder,
|
||||
useGlobalOutputDir: !task.outputDir,
|
||||
scope: task.template.scope,
|
||||
contentType: task.template.contentType,
|
||||
optionTemplate: task.template.optionTemplate,
|
||||
dateRangeConfig: task.template.dateRangeConfig,
|
||||
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
|
||||
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
|
||||
stopAtEnabled: stopAt > 0,
|
||||
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
|
||||
maxRunsEnabled: maxRuns > 0,
|
||||
maxRuns: maxRuns > 0 ? maxRuns : 0
|
||||
})
|
||||
setIsAutomationModalOpen(true)
|
||||
}, [exportFolder])
|
||||
|
||||
const openAutomationDateRangeDialog = useCallback(() => {
|
||||
if (!automationTaskDraft) return
|
||||
void (async () => {
|
||||
if (isResolvingAutomationRangeBounds) return
|
||||
setIsResolvingAutomationRangeBounds(true)
|
||||
try {
|
||||
const nextBounds = await resolveChatExportTimeRangeBounds(automationTaskDraft.sessionIds)
|
||||
setAutomationRangeBounds(nextBounds)
|
||||
if (nextBounds) {
|
||||
const nextSelection = clampExportSelectionToBounds(automationRangeSelection, nextBounds)
|
||||
if (!areExportSelectionsEqual(nextSelection, automationRangeSelection)) {
|
||||
setAutomationRangeSelection(nextSelection)
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
|
||||
} : prev)
|
||||
}
|
||||
}
|
||||
setIsAutomationRangeDialogOpen(true)
|
||||
} catch (error) {
|
||||
console.error('自动化导出解析时间范围边界失败', error)
|
||||
setAutomationRangeBounds(null)
|
||||
setIsAutomationRangeDialogOpen(true)
|
||||
} finally {
|
||||
setIsResolvingAutomationRangeBounds(false)
|
||||
}
|
||||
})()
|
||||
}, [
|
||||
automationRangeSelection,
|
||||
automationTaskDraft,
|
||||
isResolvingAutomationRangeBounds,
|
||||
resolveChatExportTimeRangeBounds
|
||||
])
|
||||
|
||||
const applyAutomationRangeMode = useCallback((mode: AutomationRangeMode) => {
|
||||
if (!automationTaskDraft) return
|
||||
if (mode === 'custom') {
|
||||
openAutomationDateRangeDialog()
|
||||
return
|
||||
}
|
||||
if (mode === 'lastNDays') {
|
||||
const relativeDays = readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT
|
||||
const nextSelection: ExportDateRangeSelection = {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByLastNDays(relativeDays, new Date())
|
||||
}
|
||||
setAutomationRangeSelection(nextSelection)
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
dateRangeConfig: buildAutomationLastNDaysConfig(relativeDays)
|
||||
} : prev)
|
||||
return
|
||||
}
|
||||
const nextSelection = createAutomationSelectionByMode(mode, new Date())
|
||||
setAutomationRangeSelection(nextSelection)
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
|
||||
} : prev)
|
||||
}, [automationTaskDraft, openAutomationDateRangeDialog])
|
||||
|
||||
const updateAutomationLastNDays = useCallback((value: unknown) => {
|
||||
const days = normalizeAutomationLastNDays(value)
|
||||
const nextSelection: ExportDateRangeSelection = {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByLastNDays(days, new Date())
|
||||
}
|
||||
setAutomationRangeSelection(nextSelection)
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
dateRangeConfig: buildAutomationLastNDaysConfig(days)
|
||||
} : prev)
|
||||
}, [])
|
||||
|
||||
const saveAutomationTaskDraft = useCallback(() => {
|
||||
if (!automationTaskDraft) return
|
||||
if (!automationTasksReadyRef.current) {
|
||||
automationTasksReadyRef.current = true
|
||||
}
|
||||
const normalizedName = automationTaskDraft.name.trim()
|
||||
if (!normalizedName) {
|
||||
window.alert('请输入任务名称')
|
||||
return
|
||||
}
|
||||
if (automationTaskDraft.sessionIds.length === 0) {
|
||||
window.alert('自动化任务至少需要一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
const intervalDays = normalizeAutomationIntervalDays(automationTaskDraft.intervalDays)
|
||||
const intervalHours = normalizeAutomationIntervalHours(automationTaskDraft.intervalHours)
|
||||
if (intervalDays <= 0 && intervalHours <= 0) {
|
||||
window.alert('执行间隔不能为 0,请至少设置天数或小时')
|
||||
return
|
||||
}
|
||||
const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours }
|
||||
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
|
||||
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
|
||||
: null
|
||||
if (automationTaskDraft.stopAtEnabled && !stopAtTimestamp) {
|
||||
window.alert('请填写有效的终止时间')
|
||||
return
|
||||
}
|
||||
const maxRuns = automationTaskDraft.maxRunsEnabled
|
||||
? Math.max(0, Math.floor(Number(automationTaskDraft.maxRuns || 0)))
|
||||
: 0
|
||||
if (automationTaskDraft.maxRunsEnabled && maxRuns <= 0) {
|
||||
window.alert('请填写大于 0 的最大执行次数')
|
||||
return
|
||||
}
|
||||
const stopCondition = {
|
||||
endAt: stopAtTimestamp && stopAtTimestamp > 0 ? stopAtTimestamp : undefined,
|
||||
maxRuns: maxRuns > 0 ? maxRuns : undefined
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const condition: ExportAutomationCondition = { type: 'new-message-since-last-success' }
|
||||
const nextTask: ExportAutomationTask = {
|
||||
id: automationTaskDraft.mode === 'edit' && automationTaskDraft.id
|
||||
? automationTaskDraft.id
|
||||
: createAutomationTaskId(),
|
||||
name: normalizedName,
|
||||
enabled: automationTaskDraft.enabled,
|
||||
sessionIds: [...automationTaskDraft.sessionIds],
|
||||
sessionNames: [...automationTaskDraft.sessionNames],
|
||||
outputDir: automationTaskDraft.useGlobalOutputDir ? undefined : String(automationTaskDraft.outputDir || '').trim(),
|
||||
schedule,
|
||||
condition,
|
||||
stopCondition: (stopCondition.endAt || stopCondition.maxRuns) ? stopCondition : undefined,
|
||||
template: {
|
||||
scope: automationTaskDraft.scope,
|
||||
contentType: automationTaskDraft.contentType,
|
||||
optionTemplate: { ...automationTaskDraft.optionTemplate },
|
||||
dateRangeConfig: automationTaskDraft.dateRangeConfig
|
||||
},
|
||||
runState: automationTaskDraft.mode === 'edit'
|
||||
? automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.runState
|
||||
: { lastRunStatus: 'idle', successCount: 0 },
|
||||
createdAt: automationTaskDraft.mode === 'edit'
|
||||
? (automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.createdAt || now)
|
||||
: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
updateAutomationTasks((prev) => {
|
||||
if (automationTaskDraft.mode === 'edit' && automationTaskDraft.id) {
|
||||
return prev.map((task) => (task.id === automationTaskDraft.id ? nextTask : task))
|
||||
}
|
||||
return [nextTask, ...prev]
|
||||
})
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
setAutomationHint(automationTaskDraft.mode === 'edit' ? '自动化任务已更新' : '自动化任务已创建')
|
||||
}, [automationTaskDraft, updateAutomationTasks])
|
||||
|
||||
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
|
||||
setLastExportBySession(prev => {
|
||||
const next = { ...prev }
|
||||
@@ -4963,10 +5494,123 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const enqueueExportTask = useCallback((title: string, payload: ExportTaskPayload): string => {
|
||||
const task: ExportTask = {
|
||||
id: createTaskId(),
|
||||
title,
|
||||
status: 'queued',
|
||||
settledSessionIds: [],
|
||||
createdAt: Date.now(),
|
||||
payload,
|
||||
progress: createEmptyProgress(),
|
||||
performance: payload.scope === 'content' && payload.contentType === 'text'
|
||||
? createEmptyTaskPerformance()
|
||||
: undefined
|
||||
}
|
||||
setTasks(prev => [task, ...prev])
|
||||
return task.id
|
||||
}, [])
|
||||
|
||||
const buildAutomationExportOptions = useCallback((task: ExportAutomationTask): ElectronExportOptions => {
|
||||
const selection = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
||||
const dateRange = selection.useAllTime
|
||||
? null
|
||||
: {
|
||||
start: Math.floor(selection.dateRange.start.getTime() / 1000),
|
||||
end: Math.floor(selection.dateRange.end.getTime() / 1000)
|
||||
}
|
||||
return {
|
||||
...task.template.optionTemplate,
|
||||
dateRange
|
||||
}
|
||||
}, [])
|
||||
|
||||
const enqueueAutomationTask = useCallback((
|
||||
task: ExportAutomationTask,
|
||||
options?: { scheduleKey?: string; force?: boolean; reason?: string }
|
||||
): { queued: boolean; reason?: string } => {
|
||||
const outputDir = String(task.outputDir || exportFolder || '').trim()
|
||||
if (!outputDir) {
|
||||
return { queued: false, reason: '导出目录未设置' }
|
||||
}
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
return { queued: false, reason: '任务已有执行队列,本次触发已跳过' }
|
||||
}
|
||||
|
||||
const exportOptions = buildAutomationExportOptions(task)
|
||||
const contentType = task.template.contentType
|
||||
const title = `自动化导出:${task.name}`
|
||||
enqueueExportTask(title, {
|
||||
sessionIds: task.sessionIds,
|
||||
sessionNames: task.sessionNames,
|
||||
outputDir,
|
||||
options: exportOptions,
|
||||
scope: task.template.scope,
|
||||
source: 'automation',
|
||||
automationTaskId: task.id,
|
||||
contentType
|
||||
})
|
||||
const now = Date.now()
|
||||
patchAutomationTask(task.id, (prev) => ({
|
||||
...prev,
|
||||
updatedAt: now,
|
||||
runState: {
|
||||
...(prev.runState || {}),
|
||||
lastRunStatus: 'queued',
|
||||
lastTriggeredAt: now,
|
||||
lastSkipReason: undefined,
|
||||
lastError: undefined,
|
||||
lastScheduleKey: options?.scheduleKey || prev.runState?.lastScheduleKey
|
||||
}
|
||||
}))
|
||||
if (options?.reason) {
|
||||
setAutomationHint(options.reason)
|
||||
}
|
||||
return { queued: true }
|
||||
}, [
|
||||
buildAutomationExportOptions,
|
||||
enqueueExportTask,
|
||||
exportFolder,
|
||||
patchAutomationTask
|
||||
])
|
||||
|
||||
const resolveAutomationHasNewMessages = useCallback(async (task: ExportAutomationTask): Promise<{ shouldRun: boolean; reason?: string }> => {
|
||||
const lastSuccessAt = Number(task.runState?.lastSuccessAt || 0)
|
||||
if (!lastSuccessAt) return { shouldRun: true }
|
||||
const stats = await window.electronAPI.chat.getExportSessionStats(task.sessionIds, {
|
||||
includeRelations: false,
|
||||
allowStaleCache: true
|
||||
})
|
||||
if (!stats.success || !stats.data) {
|
||||
return { shouldRun: false, reason: stats.error || '会话统计失败,已跳过' }
|
||||
}
|
||||
let latestTimestamp = 0
|
||||
for (const sessionId of task.sessionIds) {
|
||||
const raw = Number(stats.data?.[sessionId]?.lastTimestamp || 0)
|
||||
if (Number.isFinite(raw) && raw > latestTimestamp) {
|
||||
latestTimestamp = Math.max(0, Math.floor(raw))
|
||||
}
|
||||
}
|
||||
if (latestTimestamp <= 0) {
|
||||
return { shouldRun: false, reason: '未检测到可用会话时间戳,已跳过' }
|
||||
}
|
||||
const lastSuccessSeconds = Math.floor(lastSuccessAt / 1000)
|
||||
if (latestTimestamp <= lastSuccessSeconds) {
|
||||
return { shouldRun: false, reason: '目标会话无新消息,本次已跳过' }
|
||||
}
|
||||
return { shouldRun: true }
|
||||
}, [])
|
||||
|
||||
const createTask = async () => {
|
||||
if (!exportDialog.open || !exportFolder) return
|
||||
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
|
||||
|
||||
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
|
||||
const exportOptions = exportDialog.scope === 'sns'
|
||||
? undefined
|
||||
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
|
||||
@@ -4982,30 +5626,58 @@ function ExportPage() {
|
||||
? '朋友圈批量导出'
|
||||
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
|
||||
|
||||
const task: ExportTask = {
|
||||
id: createTaskId(),
|
||||
title,
|
||||
status: 'queued',
|
||||
settledSessionIds: [],
|
||||
createdAt: Date.now(),
|
||||
payload: {
|
||||
sessionIds: exportDialog.sessionIds,
|
||||
sessionNames: exportDialog.sessionNames,
|
||||
if (isAutomationCreateIntent) {
|
||||
if (!exportOptions || exportDialog.scope === 'sns') {
|
||||
window.alert('自动化任务仅支持会话导出')
|
||||
return
|
||||
}
|
||||
const { dateRange: _discard, ...optionTemplate } = exportOptions
|
||||
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection)
|
||||
const scope = exportDialog.scope === 'single'
|
||||
? 'single'
|
||||
: exportDialog.scope === 'content'
|
||||
? 'content'
|
||||
: 'multi'
|
||||
setAutomationRangeSelection(normalizedRangeSelection)
|
||||
setAutomationRangeBounds(null)
|
||||
setAutomationTaskDraft({
|
||||
mode: 'create',
|
||||
name: exportDialog.sessionIds.length === 1
|
||||
? `${exportDialog.sessionNames[0] || '单会话'} 自动化导出`
|
||||
: `自动化导出(${exportDialog.sessionIds.length} 个会话)`,
|
||||
enabled: true,
|
||||
sessionIds: [...exportDialog.sessionIds],
|
||||
sessionNames: [...exportDialog.sessionNames],
|
||||
outputDir: exportFolder,
|
||||
options: exportOptions,
|
||||
scope: exportDialog.scope,
|
||||
contentType: exportDialog.contentType,
|
||||
snsOptions
|
||||
},
|
||||
progress: createEmptyProgress(),
|
||||
performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text'
|
||||
? createEmptyTaskPerformance()
|
||||
: undefined
|
||||
useGlobalOutputDir: true,
|
||||
scope,
|
||||
contentType: scope === 'content' ? exportDialog.contentType : undefined,
|
||||
optionTemplate,
|
||||
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
|
||||
intervalDays: 1,
|
||||
intervalHours: 0,
|
||||
stopAtEnabled: false,
|
||||
stopAtValue: '',
|
||||
maxRunsEnabled: false,
|
||||
maxRuns: 0
|
||||
})
|
||||
setIsAutomationCreateMode(false)
|
||||
setAutomationHint('导出配置已完成,请继续设置自动化规则并保存任务')
|
||||
closeExportDialog()
|
||||
} else {
|
||||
enqueueExportTask(title, {
|
||||
sessionIds: exportDialog.sessionIds,
|
||||
sessionNames: exportDialog.sessionNames,
|
||||
outputDir: exportFolder,
|
||||
options: exportOptions,
|
||||
scope: exportDialog.scope,
|
||||
source: 'manual',
|
||||
contentType: exportDialog.contentType,
|
||||
snsOptions
|
||||
})
|
||||
closeExportDialog()
|
||||
}
|
||||
|
||||
setTasks(prev => [task, ...prev])
|
||||
closeExportDialog()
|
||||
|
||||
await configService.setExportDefaultFormat(options.format)
|
||||
await configService.setExportDefaultAvatars(options.exportAvatars)
|
||||
await configService.setExportDefaultMedia({
|
||||
@@ -5062,6 +5734,30 @@ function ExportPage() {
|
||||
.map((item) => item.session)
|
||||
}, [resolveSessionExistingMessageCount])
|
||||
|
||||
const exitAutomationCreateMode = useCallback(() => {
|
||||
setIsAutomationCreateMode(false)
|
||||
setAutomationHint('已退出自动化任务创建')
|
||||
}, [])
|
||||
|
||||
const openAutomationExportConfigDialog = useCallback(() => {
|
||||
const selectedSet = new Set(selectedSessions)
|
||||
const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
|
||||
const orderedRows = orderSessionsForExport(selectedRows)
|
||||
if (orderedRows.length === 0) {
|
||||
window.alert('请先勾选至少一个可导出的会话')
|
||||
return
|
||||
}
|
||||
const ids = orderedRows.map((session) => session.username)
|
||||
const names = orderedRows.map((session) => session.displayName || session.username)
|
||||
openExportDialog({
|
||||
scope: 'multi',
|
||||
sessionIds: ids,
|
||||
sessionNames: names,
|
||||
title: `自动化任务导出配置(${ids.length} 个会话)`,
|
||||
intent: 'automation-create'
|
||||
})
|
||||
}, [openExportDialog, orderSessionsForExport, selectedSessions, sessions])
|
||||
|
||||
const openBatchExport = () => {
|
||||
const selectedSet = new Set(selectedSessions)
|
||||
const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
|
||||
@@ -5147,6 +5843,181 @@ function ExportPage() {
|
||||
[tasks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const previous = automationQueueStatusByTaskIdRef.current
|
||||
const next = new Map<string, TaskStatus>()
|
||||
for (const task of tasks) {
|
||||
if (task.payload.source !== 'automation' || !task.payload.automationTaskId) continue
|
||||
const automationTaskId = task.payload.automationTaskId
|
||||
next.set(task.id, task.status)
|
||||
const previousStatus = previous.get(task.id)
|
||||
if (previousStatus === task.status) continue
|
||||
|
||||
const now = Date.now()
|
||||
if (task.status === 'running') {
|
||||
patchAutomationTask(automationTaskId, (current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
runState: {
|
||||
...(current.runState || {}),
|
||||
lastRunStatus: 'running',
|
||||
lastStartedAt: now,
|
||||
lastSkipReason: undefined,
|
||||
lastError: undefined
|
||||
}
|
||||
}))
|
||||
} else if (task.status === 'success') {
|
||||
patchAutomationTask(automationTaskId, (current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
runState: {
|
||||
...(current.runState || {}),
|
||||
lastRunStatus: 'success',
|
||||
lastFinishedAt: now,
|
||||
lastSuccessAt: now,
|
||||
lastSkipReason: undefined,
|
||||
lastError: undefined,
|
||||
successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + 1
|
||||
}
|
||||
}))
|
||||
} else if (task.status === 'error') {
|
||||
patchAutomationTask(automationTaskId, (current) => ({
|
||||
...current,
|
||||
updatedAt: now,
|
||||
runState: {
|
||||
...(current.runState || {}),
|
||||
lastRunStatus: 'error',
|
||||
lastFinishedAt: now,
|
||||
lastError: task.error || '导出失败'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
automationQueueStatusByTaskIdRef.current = next
|
||||
}, [patchAutomationTask, tasks])
|
||||
|
||||
const evaluateAutomationSchedules = useCallback(async () => {
|
||||
if (!automationTasksReadyRef.current) return
|
||||
if (automationSchedulerRunningRef.current) return
|
||||
automationSchedulerRunningRef.current = true
|
||||
try {
|
||||
const now = new Date()
|
||||
const enabledTasks = automationTasksRef.current.filter((task) => task.enabled)
|
||||
for (const task of enabledTasks) {
|
||||
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
|
||||
const maxRuns = Math.max(0, Math.floor(Number(task.stopCondition?.maxRuns || 0)))
|
||||
if (maxRuns > 0 && successCount >= maxRuns) {
|
||||
const stopAt = Date.now()
|
||||
patchAutomationTask(task.id, (current) => ({
|
||||
...current,
|
||||
enabled: false,
|
||||
updatedAt: stopAt,
|
||||
runState: {
|
||||
...(current.runState || {}),
|
||||
lastRunStatus: 'skipped',
|
||||
lastSkipAt: stopAt,
|
||||
lastSkipReason: `已达到最大执行次数(${maxRuns} 次),任务已自动停用`,
|
||||
successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0)))
|
||||
}
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
const endAt = Number(task.stopCondition?.endAt || 0)
|
||||
if (endAt > 0 && now.getTime() > endAt) {
|
||||
const stopAt = Date.now()
|
||||
patchAutomationTask(task.id, (current) => ({
|
||||
...current,
|
||||
enabled: false,
|
||||
updatedAt: stopAt,
|
||||
runState: {
|
||||
...(current.runState || {}),
|
||||
lastRunStatus: 'skipped',
|
||||
lastSkipAt: stopAt,
|
||||
lastSkipReason: '已超过终止时间,任务已自动停用'
|
||||
}
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
const scheduleKey = resolveAutomationDueScheduleKey(task, now)
|
||||
if (!scheduleKey) continue
|
||||
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
||||
|
||||
const hasConflict = tasksRef.current.some((item) => {
|
||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
||||
return item.payload.automationTaskId === task.id
|
||||
})
|
||||
if (hasConflict) {
|
||||
markAutomationTaskSkipped(task.id, '任务仍在执行中,本次触发已跳过', scheduleKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.condition.type === 'new-message-since-last-success') {
|
||||
const checkResult = await resolveAutomationHasNewMessages(task)
|
||||
if (!checkResult.shouldRun) {
|
||||
markAutomationTaskSkipped(task.id, checkResult.reason || '无新消息,本次触发已跳过', scheduleKey)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const queued = enqueueAutomationTask(task, { scheduleKey })
|
||||
if (!queued.queued) {
|
||||
markAutomationTaskSkipped(task.id, queued.reason || '触发失败,本次已跳过', scheduleKey)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
automationSchedulerRunningRef.current = false
|
||||
}
|
||||
}, [
|
||||
enqueueAutomationTask,
|
||||
markAutomationTaskSkipped,
|
||||
patchAutomationTask,
|
||||
resolveAutomationHasNewMessages
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
if (cancelled) return
|
||||
if (!automationTasksReadyRef.current) return
|
||||
try {
|
||||
await evaluateAutomationSchedules()
|
||||
} catch (error) {
|
||||
console.error('自动化导出调度失败:', error)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
const timer = window.setInterval(() => {
|
||||
void run()
|
||||
}, 30_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [evaluateAutomationSchedules])
|
||||
|
||||
const runAutomationTaskNow = useCallback((taskId: string) => {
|
||||
const target = automationTasksRef.current.find((task) => task.id === taskId)
|
||||
if (!target) return
|
||||
const queued = enqueueAutomationTask(target, {
|
||||
reason: `已手动触发「${target.name}」`,
|
||||
scheduleKey: target.runState?.lastScheduleKey
|
||||
})
|
||||
if (!queued.queued) {
|
||||
markAutomationTaskSkipped(taskId, queued.reason || '手动触发失败')
|
||||
setAutomationHint(queued.reason || '手动触发失败')
|
||||
return
|
||||
}
|
||||
setAutomationHint(`已加入队列:${target.name}`)
|
||||
}, [enqueueAutomationTask, markAutomationTaskSkipped])
|
||||
|
||||
useEffect(() => {
|
||||
if (!automationHint) return
|
||||
const timer = window.setTimeout(() => setAutomationHint(null), 2600)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [automationHint])
|
||||
|
||||
const inProgressSessionIdsKey = useMemo(
|
||||
() => inProgressSessionIds.join('||'),
|
||||
[inProgressSessionIds]
|
||||
@@ -6497,6 +7368,7 @@ function ExportPage() {
|
||||
const canCreateTask = exportDialog.scope === 'sns'
|
||||
? Boolean(exportFolder)
|
||||
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
|
||||
const isAutomationCreateDialog = exportDialog.intent === 'automation-create'
|
||||
const scopeLabel = exportDialog.scope === 'single'
|
||||
? '单会话'
|
||||
: exportDialog.scope === 'multi'
|
||||
@@ -6815,6 +7687,43 @@ function ExportPage() {
|
||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||
}, [])
|
||||
|
||||
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
||||
const now = Date.now()
|
||||
patchAutomationTask(taskId, (task) => ({
|
||||
...task,
|
||||
enabled,
|
||||
updatedAt: now
|
||||
}))
|
||||
setAutomationHint(enabled ? '自动化任务已启用' : '自动化任务已停用')
|
||||
}, [patchAutomationTask])
|
||||
|
||||
const deleteAutomationTask = useCallback((taskId: string) => {
|
||||
const target = automationTasksRef.current.find((task) => task.id === taskId)
|
||||
if (!target) return
|
||||
const confirmed = window.confirm(`确认删除自动化任务「${target.name}」吗?`)
|
||||
if (!confirmed) return
|
||||
updateAutomationTasks((prev) => prev.filter((task) => task.id !== taskId))
|
||||
setAutomationHint('自动化任务已删除')
|
||||
}, [updateAutomationTasks])
|
||||
|
||||
const chooseAutomationDraftOutputDir = useCallback(async () => {
|
||||
if (!automationTaskDraft) return
|
||||
const result = await window.electronAPI.dialog.openFile({
|
||||
title: '选择任务导出目录',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return
|
||||
const outputDir = result.filePaths[0]
|
||||
setAutomationTaskDraft((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
outputDir,
|
||||
useGlobalOutputDir: false
|
||||
}
|
||||
})
|
||||
}, [automationTaskDraft])
|
||||
const renderContactRow = useCallback((index: number, contact: ContactInfo) => {
|
||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||
const canExport = Boolean(matchedSession?.hasSession)
|
||||
@@ -6824,6 +7733,7 @@ function ExportPage() {
|
||||
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
||||
const recentExportTimestamp = lastExportBySession[contact.username]
|
||||
const hasRecentExport = canExport && Boolean(recentExportTimestamp)
|
||||
const showRecentExport = !isAutomationCreateMode && hasRecentExport
|
||||
const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : ''
|
||||
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||
@@ -7014,24 +7924,26 @@ function ExportPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="row-action-cell">
|
||||
<div className={`row-action-main ${hasRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
<div className={`row-export-action-stack ${hasRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
|
||||
disabled={!canExport || isRunning}
|
||||
onClick={() => {
|
||||
if (!matchedSession || !matchedSession.hasSession) return
|
||||
openSingleExport({
|
||||
...matchedSession,
|
||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||
})
|
||||
}}
|
||||
>
|
||||
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
|
||||
</button>
|
||||
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
|
||||
</div>
|
||||
<div className={`row-action-main ${showRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
{!isAutomationCreateMode && (
|
||||
<div className={`row-export-action-stack ${showRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
|
||||
disabled={!canExport || isRunning}
|
||||
onClick={() => {
|
||||
if (!matchedSession || !matchedSession.hasSession) return
|
||||
openSingleExport({
|
||||
...matchedSession,
|
||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||
})
|
||||
}}
|
||||
>
|
||||
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
|
||||
</button>
|
||||
{showRecentExport && <span className="row-export-time">{recentExportTime}</span>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
|
||||
onClick={() => openSessionDetail(contact.username)}
|
||||
@@ -7045,6 +7957,7 @@ function ExportPage() {
|
||||
)
|
||||
}, [
|
||||
filteredContacts,
|
||||
isAutomationCreateMode,
|
||||
lastExportBySession,
|
||||
navigate,
|
||||
nowTick,
|
||||
@@ -7142,6 +8055,10 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sortedAutomationTasks = useMemo(() => (
|
||||
[...automationTasks].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
|
||||
), [automationTasks])
|
||||
|
||||
return (
|
||||
<div className="export-board-page">
|
||||
<div className="export-top-panel">
|
||||
@@ -7183,6 +8100,16 @@ function ExportPage() {
|
||||
/>
|
||||
|
||||
<div className="more-export-settings-control">
|
||||
<button
|
||||
className="more-export-settings-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAutomationCreateMode(false)
|
||||
setIsAutomationModalOpen(true)
|
||||
}}
|
||||
>
|
||||
自动化导出
|
||||
</button>
|
||||
<button
|
||||
className="more-export-settings-btn"
|
||||
type="button"
|
||||
@@ -7204,6 +8131,9 @@ function ExportPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{automationHint && (
|
||||
<div className="automation-hint-pill">{automationHint}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskCenterModal
|
||||
@@ -7217,6 +8147,368 @@ function ExportPage() {
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
/>
|
||||
|
||||
{isAutomationModalOpen && createPortal(
|
||||
<div
|
||||
className="automation-modal-overlay"
|
||||
onClick={() => {
|
||||
setIsAutomationModalOpen(false)
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="automation-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="自动化导出任务"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="automation-modal-header">
|
||||
<div>
|
||||
<h3>自动化导出</h3>
|
||||
<p>仅在应用运行期间生效;错过触发不会补跑。</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
onClick={openCreateAutomationDraft}
|
||||
>
|
||||
新建任务
|
||||
</button>
|
||||
<button
|
||||
className="close-icon-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAutomationModalOpen(false)
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}
|
||||
aria-label="关闭自动化导出"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="automation-modal-body">
|
||||
{sortedAutomationTasks.length === 0 ? (
|
||||
<div className="automation-empty">
|
||||
暂无自动化任务。点击右上角「新建任务」开始配置。
|
||||
</div>
|
||||
) : (
|
||||
<div className="automation-task-list">
|
||||
{sortedAutomationTasks.map((task) => {
|
||||
const linkedQueueTask = tasks.find((item) => (
|
||||
(item.status === 'running' || item.status === 'queued') &&
|
||||
item.payload.automationTaskId === task.id
|
||||
))
|
||||
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
||||
? 'running'
|
||||
: linkedQueueTask?.status === 'queued'
|
||||
? 'queued'
|
||||
: null
|
||||
return (
|
||||
<div key={task.id} className={`automation-task-card ${task.enabled ? '' : 'disabled'}`.trim()}>
|
||||
<div className="automation-task-main">
|
||||
<div className="automation-task-title-row">
|
||||
<strong>{task.name}</strong>
|
||||
<span className={`automation-task-status ${task.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{task.enabled ? '已启用' : '已停用'}
|
||||
</span>
|
||||
{queueState === 'running' && <span className="automation-task-status running">执行中</span>}
|
||||
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
|
||||
</div>
|
||||
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
|
||||
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
|
||||
<p>会话范围:{task.sessionIds.length} 个</p>
|
||||
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
|
||||
<p>当前状态:{formatAutomationCurrentState(task, queueState, nowTick)}</p>
|
||||
<p>终止条件:{formatAutomationStopCondition(task)}</p>
|
||||
<p>最近结果:{formatAutomationLastRunSummary(task)}</p>
|
||||
</div>
|
||||
<div className="automation-task-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="task-action-btn"
|
||||
onClick={() => toggleAutomationTaskEnabled(task.id, !task.enabled)}
|
||||
>
|
||||
{task.enabled ? '停用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="task-action-btn"
|
||||
onClick={() => runAutomationTaskNow(task.id)}
|
||||
>
|
||||
立即执行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="task-action-btn"
|
||||
onClick={() => openEditAutomationTaskDraft(task)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="task-action-btn danger"
|
||||
onClick={() => deleteAutomationTask(task.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{automationTaskDraft && createPortal(
|
||||
<div className="automation-editor-overlay" onClick={() => {
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}>
|
||||
<div
|
||||
className="automation-editor-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑自动化任务"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="automation-editor-header">
|
||||
<h3>{automationTaskDraft.mode === 'edit' ? '编辑自动化任务' : '创建自动化任务'}</h3>
|
||||
<button
|
||||
className="close-icon-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}
|
||||
aria-label="关闭自动化任务编辑"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="automation-editor-body">
|
||||
<label className="automation-form-field">
|
||||
<span>任务名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={automationTaskDraft.name}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? { ...prev, name: event.target.value } : prev)}
|
||||
placeholder="例如:工作日会话归档"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="automation-inline-time">
|
||||
<label className="automation-form-field">
|
||||
<span>间隔天数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={automationTaskDraft.intervalDays}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
intervalDays: normalizeAutomationIntervalDays(event.target.value)
|
||||
} : prev)}
|
||||
/>
|
||||
</label>
|
||||
<label className="automation-form-field">
|
||||
<span>间隔小时</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={automationTaskDraft.intervalHours}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
intervalHours: normalizeAutomationIntervalHours(event.target.value)
|
||||
} : prev)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>导出时间范围(按触发时间动态计算)</span>
|
||||
<div className="automation-segment-row">
|
||||
{AUTOMATION_RANGE_OPTIONS.map((option) => {
|
||||
const active = resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === option.mode
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
className={`automation-segment-btn ${active ? 'active' : ''}`.trim()}
|
||||
onClick={() => applyAutomationRangeMode(option.mode)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'lastNDays' && (
|
||||
<label className="automation-form-field">
|
||||
<span>往前天数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={AUTOMATION_LAST_N_DAYS_MIN}
|
||||
max={AUTOMATION_LAST_N_DAYS_MAX}
|
||||
value={readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT}
|
||||
onChange={(event) => updateAutomationLastNDays(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="automation-path-row">
|
||||
<span>{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)}</span>
|
||||
{resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="task-action-btn"
|
||||
onClick={openAutomationDateRangeDialog}
|
||||
disabled={isResolvingAutomationRangeBounds}
|
||||
>
|
||||
{isResolvingAutomationRangeBounds ? '解析中...' : '编辑完整时间'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>终止条件(可选)</span>
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.stopAtEnabled}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
stopAtEnabled: event.target.checked
|
||||
} : prev)}
|
||||
/>
|
||||
到指定时间后自动停止
|
||||
</label>
|
||||
{automationTaskDraft.stopAtEnabled && (
|
||||
<div className="automation-stopat-picker">
|
||||
<input
|
||||
type="date"
|
||||
className="automation-stopat-date"
|
||||
value={automationTaskDraft.stopAtValue ? automationTaskDraft.stopAtValue.slice(0, 10) : ''}
|
||||
onChange={(e) => {
|
||||
const datePart = e.target.value
|
||||
const timePart = automationTaskDraft.stopAtValue?.slice(11) || '23:59'
|
||||
setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: datePart ? `${datePart}T${timePart}` : '' } : prev)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="automation-stopat-time"
|
||||
value={automationTaskDraft.stopAtValue ? automationTaskDraft.stopAtValue.slice(11) : '23:59'}
|
||||
onChange={(e) => {
|
||||
const timePart = e.target.value
|
||||
const datePart = automationTaskDraft.stopAtValue?.slice(0, 10) || new Date().toISOString().slice(0, 10)
|
||||
setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: `${datePart}T${timePart}` } : prev)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.maxRunsEnabled}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
maxRunsEnabled: event.target.checked
|
||||
} : prev)}
|
||||
/>
|
||||
成功执行指定次数后自动停止
|
||||
</label>
|
||||
{automationTaskDraft.maxRunsEnabled && (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={automationTaskDraft.maxRuns}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
maxRuns: Math.max(0, Math.floor(Number(event.target.value) || 0))
|
||||
} : prev)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>导出目录</span>
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.useGlobalOutputDir}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
useGlobalOutputDir: event.target.checked
|
||||
} : prev)}
|
||||
/>
|
||||
使用全局导出目录
|
||||
</label>
|
||||
{!automationTaskDraft.useGlobalOutputDir && (
|
||||
<div className="automation-path-row">
|
||||
<button type="button" className="task-action-btn" onClick={() => void chooseAutomationDraftOutputDir()}>
|
||||
选择目录
|
||||
</button>
|
||||
<span>{automationTaskDraft.outputDir || '未设置'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.enabled}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? { ...prev, enabled: event.target.checked } : prev)}
|
||||
/>
|
||||
创建后立即启用
|
||||
</label>
|
||||
|
||||
<div className="automation-draft-summary">
|
||||
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
|
||||
</div>
|
||||
</div>
|
||||
<div className="automation-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
onClick={() => {
|
||||
setAutomationTaskDraft(null)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="primary-btn" onClick={saveAutomationTaskDraft}>保存任务</button>
|
||||
</div>
|
||||
<ExportDateRangeDialog
|
||||
open={isAutomationRangeDialogOpen}
|
||||
value={automationRangeSelection}
|
||||
minDate={automationRangeBounds?.minDate}
|
||||
maxDate={automationRangeBounds?.maxDate}
|
||||
onClose={() => setIsAutomationRangeDialogOpen(false)}
|
||||
onConfirm={(nextSelection) => {
|
||||
setAutomationRangeSelection(nextSelection)
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
|
||||
} : prev)
|
||||
setIsAutomationRangeDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{isExportDefaultsModalOpen && createPortal(
|
||||
<div
|
||||
className="export-defaults-modal-overlay"
|
||||
@@ -7307,24 +8599,26 @@ function ExportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className={`card-export-btn ${isPrimaryCard ? 'primary' : 'secondary'} ${isCardRunning ? 'running' : ''}`}
|
||||
disabled={isCardRunning}
|
||||
onClick={() => {
|
||||
if (card.type === 'sns') {
|
||||
openSnsExport()
|
||||
return
|
||||
}
|
||||
openContentExport(card.type)
|
||||
}}
|
||||
>
|
||||
{isCardRunning ? (
|
||||
<>
|
||||
<span>批量导出中</span>
|
||||
<Loader2 size={14} className="spin" />
|
||||
</>
|
||||
) : '批量导出'}
|
||||
</button>
|
||||
{!isAutomationCreateMode && (
|
||||
<button
|
||||
className={`card-export-btn ${isPrimaryCard ? 'primary' : 'secondary'} ${isCardRunning ? 'running' : ''}`}
|
||||
disabled={isCardRunning}
|
||||
onClick={() => {
|
||||
if (card.type === 'sns') {
|
||||
openSnsExport()
|
||||
return
|
||||
}
|
||||
openContentExport(card.type)
|
||||
}}
|
||||
>
|
||||
{isCardRunning ? (
|
||||
<>
|
||||
<span>批量导出中</span>
|
||||
<Loader2 size={14} className="spin" />
|
||||
</>
|
||||
) : '批量导出'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -7340,6 +8634,18 @@ function ExportPage() {
|
||||
'你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。'
|
||||
]}
|
||||
/>
|
||||
{isAutomationCreateMode && (
|
||||
<div className="automation-create-mode-pill">
|
||||
<span>自动化创建中:先勾选联系人,再点击「加入任务」</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
onClick={exitAutomationCreateMode}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()}
|
||||
type="button"
|
||||
@@ -7450,6 +8756,15 @@ function ExportPage() {
|
||||
<span className="contacts-list-header-media">共同好友</span>
|
||||
)}
|
||||
<span className="contacts-list-header-actions">
|
||||
{isAutomationCreateMode && (
|
||||
<button
|
||||
className="selection-clear-btn"
|
||||
type="button"
|
||||
onClick={exitAutomationCreateMode}
|
||||
>
|
||||
退出创建
|
||||
</button>
|
||||
)}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<button
|
||||
@@ -7462,9 +8777,9 @@ function ExportPage() {
|
||||
<button
|
||||
className="selection-export-btn"
|
||||
type="button"
|
||||
onClick={openBatchExport}
|
||||
onClick={isAutomationCreateMode ? openAutomationExportConfigDialog : openBatchExport}
|
||||
>
|
||||
<span>批量导出</span>
|
||||
<span>{isAutomationCreateMode ? '加入任务' : '批量导出'}</span>
|
||||
<span className="selection-export-count">{selectedCount}</span>
|
||||
</button>
|
||||
</>
|
||||
@@ -8299,20 +9614,22 @@ function ExportPage() {
|
||||
|
||||
|
||||
|
||||
<div className="dialog-section">
|
||||
<div className="section-header-action">
|
||||
<h4>时间范围</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="time-range-trigger"
|
||||
onClick={openTimeRangeDialog}
|
||||
disabled={isResolvingTimeRangeBounds}
|
||||
>
|
||||
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
</button>
|
||||
{!isAutomationCreateDialog && (
|
||||
<div className="dialog-section">
|
||||
<div className="section-header-action">
|
||||
<h4>时间范围</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="time-range-trigger"
|
||||
onClick={openTimeRangeDialog}
|
||||
disabled={isResolvingTimeRangeBounds}
|
||||
>
|
||||
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowMediaSection && (
|
||||
<div className="dialog-section">
|
||||
@@ -8490,7 +9807,7 @@ function ExportPage() {
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-btn" onClick={closeExportDialog}>取消</button>
|
||||
<button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
|
||||
<Download size={14} /> 创建导出任务
|
||||
<Download size={14} /> {isAutomationCreateDialog ? '下一步:自动化规则' : '创建导出任务'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -304,6 +304,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav-hint {
|
||||
margin-top: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
}
|
||||
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
@@ -362,6 +375,16 @@
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-mode-tip {
|
||||
margin: 10px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.step-icon-wrapper {
|
||||
@@ -556,6 +579,41 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auto-image-key-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.auto-image-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
code {
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-image-key-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
+238
-27
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
@@ -31,6 +31,7 @@ const steps = [
|
||||
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
|
||||
]
|
||||
type SetupStepId = typeof steps[number]['id']
|
||||
type ImageKeyResolveSource = 'manual-cache' | 'prefetch-cache' | 'memory-scan'
|
||||
|
||||
interface WelcomePageProps {
|
||||
standalone?: boolean
|
||||
@@ -61,9 +62,44 @@ const isDbKeyReadyMessage = (message: string): boolean => (
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
)
|
||||
|
||||
const pickWxidByAnchorTime = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>,
|
||||
anchorTime?: number
|
||||
): string => {
|
||||
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
||||
const fallbackWxid = wxids[0]?.wxid || ''
|
||||
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
|
||||
|
||||
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
||||
if (valid.length === 0) return fallbackWxid
|
||||
|
||||
const anchor = Number(anchorTime)
|
||||
const nearWindowMs = 10 * 60 * 1000
|
||||
|
||||
const near = valid
|
||||
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
|
||||
.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
if (near.length > 0) return near[0].wxid
|
||||
|
||||
const closest = valid.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
return closest[0]?.wxid || fallbackWxid
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||
const isAddAccountMode = standalone && new URLSearchParams(location.search).get('mode') === 'add-account'
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
@@ -92,7 +128,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||
const [isImageKeyVerified, setIsImageKeyVerified] = useState(false)
|
||||
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
||||
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
||||
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
||||
const imagePrefetchAttemptRef = useRef<string>('')
|
||||
|
||||
// 安全相关 state
|
||||
const [enableAuth, setEnableAuth] = useState(false)
|
||||
@@ -191,7 +231,79 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setWxidOptions([])
|
||||
setWxid('')
|
||||
setShowWxidSelect(false)
|
||||
}, [dbPath])
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
setDecryptKey('')
|
||||
}
|
||||
imagePrefetchAttemptRef.current = ''
|
||||
}, [dbPath, isAddAccountMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddAccountMode) return
|
||||
let cancelled = false
|
||||
|
||||
const hydrateAddAccountMode = async () => {
|
||||
const keyStepIndex = steps.findIndex(step => step.id === 'key')
|
||||
if (keyStepIndex >= 0) {
|
||||
setStepIndex(keyStepIndex)
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
savedDbPath,
|
||||
savedCachePath,
|
||||
savedWxid,
|
||||
savedImageXorKey,
|
||||
savedImageAesKey
|
||||
] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getCachePath(),
|
||||
configService.getMyWxid(),
|
||||
configService.getImageXorKey(),
|
||||
configService.getImageAesKey()
|
||||
])
|
||||
if (cancelled) return
|
||||
|
||||
setDbPath(savedDbPath || '')
|
||||
setCachePath(savedCachePath || '')
|
||||
setDecryptKey('')
|
||||
setHasReacquiredDbKey(false)
|
||||
if (typeof savedImageXorKey === 'number' && Number.isFinite(savedImageXorKey)) {
|
||||
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
setImageAesKey(savedImageAesKey || '')
|
||||
|
||||
if (savedDbPath) {
|
||||
const scannedWxids = await window.electronAPI.dbPath.scanWxids(savedDbPath)
|
||||
if (cancelled) return
|
||||
setWxidOptions(scannedWxids)
|
||||
|
||||
const preferredWxid = String(savedWxid || '').trim()
|
||||
const matched = scannedWxids.find(item => item.wxid === preferredWxid)
|
||||
if (matched) {
|
||||
setWxid(matched.wxid)
|
||||
} else if (preferredWxid) {
|
||||
setWxid(preferredWxid)
|
||||
} else if (scannedWxids.length > 0) {
|
||||
setWxid(scannedWxids[0].wxid)
|
||||
}
|
||||
} else if (savedWxid) {
|
||||
setWxid(savedWxid)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(`加载当前账号配置失败: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void hydrateAddAccountMode()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isAddAccountMode])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -206,10 +318,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
|
||||
const currentStep = steps[stepIndex]
|
||||
const imageStepIndex = steps.findIndex(step => step.id === 'image')
|
||||
const securityStepIndex = steps.findIndex(step => step.id === 'security')
|
||||
const currentStep = steps[stepIndex] ?? steps[0]
|
||||
const imagePreCompletedAhead = isImageStepAutoCompleted && imageStepIndex >= 0 && stepIndex < imageStepIndex
|
||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||
const showWindowControls = standalone
|
||||
|
||||
const isStepCompleted = (index: number, stepId: SetupStepId): boolean => {
|
||||
if (index < stepIndex) return true
|
||||
if (stepId === 'image' && isImageStepAutoCompleted) return true
|
||||
if (isAddAccountMode && stepId !== 'key') return true
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveStepDesc = (step: { id: SetupStepId; desc: string }): string => {
|
||||
if (step.id === 'image' && isImageStepAutoCompleted) {
|
||||
return '缓存校验成功,已自动完成'
|
||||
}
|
||||
if (isAddAccountMode && step.id !== 'key') {
|
||||
return '已沿用当前配置'
|
||||
}
|
||||
return step.desc
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI.window.minimize()
|
||||
}
|
||||
@@ -302,7 +434,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
const handleScanWxid = async (silent = false, anchorTime?: number) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) setError('请先选择数据库目录')
|
||||
return
|
||||
@@ -314,8 +446,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length > 0) {
|
||||
// scanWxids 已经按时间排过序了,直接取第一个
|
||||
setWxid(wxids[0].wxid)
|
||||
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
|
||||
// 其余场景保持“时间最新”优先。
|
||||
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
|
||||
setWxid(selectedWxid || wxids[0].wxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
if (!silent) setError('未检测到账号目录,请检查路径')
|
||||
@@ -364,10 +498,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const result = await window.electronAPI.key.autoGetDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setHasReacquiredDbKey(true)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setError('')
|
||||
await handleScanWxid(true)
|
||||
const keySuccessAt = Date.now()
|
||||
await handleScanWxid(true, keySuccessAt)
|
||||
} else {
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
}
|
||||
if (
|
||||
result.error?.includes('未找到微信安装路径') ||
|
||||
result.error?.includes('启动微信失败') ||
|
||||
@@ -396,25 +535,45 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
handleAutoGetDbKey()
|
||||
}
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
const handleAutoGetImageKey = async (
|
||||
source: ImageKeyResolveSource = 'manual-cache',
|
||||
options?: { silentError?: boolean }
|
||||
) => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
setIsFetchingImageKey(true)
|
||||
setError('')
|
||||
if (!options?.silentError) {
|
||||
setError('')
|
||||
}
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在准备获取图片密钥...')
|
||||
setImageKeyStatus(source === 'prefetch-cache' ? '正在预计算图片密钥...' : '正在准备获取图片密钥...')
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
const verified = result.verified === true
|
||||
setIsImageKeyVerified(verified)
|
||||
setIsImageStepAutoCompleted(verified)
|
||||
if (verified) {
|
||||
setImageKeyStatus(source === 'prefetch-cache' ? '图片密钥已预先自动完成(缓存校验通过)' : '图片密钥获取成功(缓存校验通过)')
|
||||
} else {
|
||||
setImageKeyStatus('已自动计算图片密钥(未完成校验)')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '自动获取图片密钥失败')
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (!options?.silentError) {
|
||||
setError(result.error || '自动获取图片密钥失败')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`自动获取图片密钥失败: ${e}`)
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (!options?.silentError) {
|
||||
setError(`自动获取图片密钥失败: ${e}`)
|
||||
}
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
@@ -433,6 +592,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||
} else {
|
||||
setError(result.error || '内存扫描获取图片密钥失败')
|
||||
@@ -444,6 +605,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dbPath || !wxid || decryptKey.length !== 64) return
|
||||
const attemptKey = `${dbPath}::${wxid}::${decryptKey}`
|
||||
if (imagePrefetchAttemptRef.current === attemptKey) return
|
||||
imagePrefetchAttemptRef.current = attemptKey
|
||||
void handleAutoGetImageKey('prefetch-cache', { silentError: true })
|
||||
}, [dbPath, wxid, decryptKey])
|
||||
|
||||
const jumpToStep = (stepId: SetupStepId) => {
|
||||
const targetIndex = steps.findIndex(step => step.id === stepId)
|
||||
if (targetIndex >= 0) setStepIndex(targetIndex)
|
||||
@@ -487,6 +656,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
const canGoNext = () => {
|
||||
if (isAddAccountMode) {
|
||||
if (currentStep.id === 'key') return hasReacquiredDbKey && decryptKey.length === 64 && Boolean(wxid)
|
||||
return true
|
||||
}
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
||||
if (currentStep.id === 'cache') return true
|
||||
@@ -502,6 +675,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isAddAccountMode) {
|
||||
await handleConnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep.id === 'db') {
|
||||
const dbStepIssue = await validateDbStepBeforeNext()
|
||||
if (dbStepIssue) {
|
||||
@@ -520,15 +698,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
if (currentStep.id === 'key' && isImageStepAutoCompleted && securityStepIndex >= 0) {
|
||||
setStepIndex(securityStepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex((prev) => Math.min(prev + 1, steps.length - 1))
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (isAddAccountMode) return
|
||||
setError('')
|
||||
setStepIndex((prev) => Math.max(prev - 1, 0))
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (isAddAccountMode && !hasReacquiredDbKey) {
|
||||
setError('请先在当前流程中自动获取一次数据库密钥')
|
||||
return
|
||||
}
|
||||
|
||||
const configIssue = await findConfigIssueBeforeConnect()
|
||||
if (configIssue) {
|
||||
setError(configIssue.message)
|
||||
@@ -708,13 +896,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
<div className="sidebar-nav">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
|
||||
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${isStepCompleted(index, step.id) ? 'completed' : ''}`}>
|
||||
<div className="nav-indicator">
|
||||
{index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||
{isStepCompleted(index, step.id) ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||
</div>
|
||||
<div className="nav-info">
|
||||
<div className="nav-title">{step.title}</div>
|
||||
<div className="nav-desc">{step.desc}</div>
|
||||
<div className="nav-desc">{resolveStepDesc(step)}</div>
|
||||
{step.id === 'image' && imagePreCompletedAhead && (
|
||||
<div className="nav-hint">已预先自动完成</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -731,6 +922,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p className="header-desc">{currentStep.desc}</p>
|
||||
{isAddAccountMode && (
|
||||
<p className="header-mode-tip">添加账号模式:其他步骤已沿用当前配置,只需重新获取数据库密钥。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -863,6 +1057,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</div>
|
||||
|
||||
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
|
||||
{isAddAccountMode && !hasReacquiredDbKey && (
|
||||
<div className="field-hint">添加账号模式下需先自动获取一次数据库密钥,才能完成并返回主窗口。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -936,19 +1133,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="form-group">
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||
<div className="auto-image-key-preview">
|
||||
<div className="auto-image-key-row">
|
||||
<span className="auto-image-key-label">图片 XOR 密钥</span>
|
||||
<code>{imageXorKey || '等待自动计算'}</code>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||
<div className="auto-image-key-row">
|
||||
<span className="auto-image-key-label">图片 AES 密钥</span>
|
||||
<code>{imageAesKey || '等待自动计算'}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||
<button className="btn btn-primary btn-block" onClick={() => handleAutoGetImageKey('manual-cache')} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||
@@ -960,13 +1157,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div className="brute-force-progress">
|
||||
<div className="status-header">
|
||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||
{typeof imageKeyPercent === 'number' && Number.isFinite(imageKeyPercent) && (
|
||||
<span className="status-text">{Math.max(0, Math.min(100, imageKeyPercent)).toFixed(1)}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||
)}
|
||||
|
||||
<div className="field-hint" style={{ marginTop: '8px' }}>优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)</div>
|
||||
<div className="field-hint" style={{ marginTop: '8px' }}>
|
||||
图片密钥已改为自动计算。仅当“缓存计算 + 本地校验通过”时会自动跳过本步骤;若失败可使用内存扫描兜底。
|
||||
</div>
|
||||
{isImageKeyVerified && (
|
||||
<div className="status-message is-success" style={{ marginTop: '8px' }}>
|
||||
当前密钥已通过缓存校验,可安全自动跳过图片密钥步骤。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -981,11 +1188,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
)}
|
||||
|
||||
<div className="content-actions">
|
||||
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
|
||||
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0 || isAddAccountMode}>
|
||||
<ArrowLeft size={16} /> 上一步
|
||||
</button>
|
||||
|
||||
{stepIndex < steps.length - 1 ? (
|
||||
{isAddAccountMode ? (
|
||||
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
||||
{isConnecting ? '连接中...' : '完成并返回'} <ArrowRight size={16} />
|
||||
</button>
|
||||
) : stepIndex < steps.length - 1 ? (
|
||||
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
||||
下一步 <ArrowRight size={16} />
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user