mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
@@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"senderUsername": "wxid_sender",
|
"senderUsername": "wxid_sender",
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "image_123.jpg",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||||
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"timestamp": 1738713600000,
|
"timestamp": 1738713600000,
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"content": "消息内容",
|
"content": "消息内容",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"media": {
|
"media": {
|
||||||
@@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 获取会话列表
|
### 3. 访问导出媒体文件
|
||||||
|
|
||||||
|
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/v1/media/{relativePath}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||||
|
|
||||||
|
**支持的媒体类型**
|
||||||
|
|
||||||
|
| 扩展名 | Content-Type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `.png` | image/png |
|
||||||
|
| `.jpg` / `.jpeg` | image/jpeg |
|
||||||
|
| `.gif` | image/gif |
|
||||||
|
| `.webp` | image/webp |
|
||||||
|
| `.wav` | audio/wav |
|
||||||
|
| `.mp3` | audio/mpeg |
|
||||||
|
| `.mp4` | video/mp4 |
|
||||||
|
|
||||||
|
**示例请求**
|
||||||
|
```bash
|
||||||
|
# 访问导出的图片
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||||
|
|
||||||
|
# 访问导出的语音
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||||
|
|
||||||
|
# 访问导出的视频
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||||
|
|
||||||
|
失败时返回:
|
||||||
|
```json
|
||||||
|
{ "error": "Media not found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 获取会话列表
|
||||||
|
|
||||||
获取所有会话列表。
|
获取所有会话列表。
|
||||||
|
|
||||||
|
|||||||
@@ -665,7 +665,18 @@ class ExportService {
|
|||||||
case 42: return '[名片]'
|
case 42: return '[名片]'
|
||||||
case 43: return '[视频]'
|
case 43: return '[视频]'
|
||||||
case 47: return '[动画表情]'
|
case 47: return '[动画表情]'
|
||||||
case 48: return '[位置]'
|
case 48: {
|
||||||
|
const normalized48 = this.normalizeAppMessageContent(content)
|
||||||
|
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
|
||||||
|
const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label')
|
||||||
|
const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude')
|
||||||
|
const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude')
|
||||||
|
const locParts: string[] = []
|
||||||
|
if (locPoiname) locParts.push(locPoiname)
|
||||||
|
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
|
||||||
|
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
|
||||||
|
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
|
||||||
|
}
|
||||||
case 49: {
|
case 49: {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const type = this.extractXmlValue(content, 'type')
|
const type = this.extractXmlValue(content, 'type')
|
||||||
@@ -776,12 +787,15 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
if (localType === 48) {
|
if (localType === 48) {
|
||||||
const normalized = this.normalizeAppMessageContent(safeContent)
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
const location =
|
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
|
||||||
this.extractXmlValue(normalized, 'label') ||
|
const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label')
|
||||||
this.extractXmlValue(normalized, 'poiname') ||
|
const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude')
|
||||||
this.extractXmlValue(normalized, 'poiName') ||
|
const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude')
|
||||||
this.extractXmlValue(normalized, 'name')
|
const locParts: string[] = []
|
||||||
return location ? `[定位]${location}` : '[定位]'
|
if (locPoiname) locParts.push(locPoiname)
|
||||||
|
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
|
||||||
|
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
|
||||||
|
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
|
||||||
}
|
}
|
||||||
if (localType === 50) {
|
if (localType === 50) {
|
||||||
return this.parseVoipMessage(safeContent)
|
return this.parseVoipMessage(safeContent)
|
||||||
@@ -979,6 +993,12 @@ class ExportService {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||||
|
const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i')
|
||||||
|
const match = tagRegex.exec(xml)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
private cleanSystemMessage(content: string): string {
|
private cleanSystemMessage(content: string): string {
|
||||||
if (!content) return '[系统消息]'
|
if (!content) return '[系统消息]'
|
||||||
|
|
||||||
@@ -2932,7 +2952,7 @@ class ExportService {
|
|||||||
options.displayNamePreference || 'remark'
|
options.displayNamePreference || 'remark'
|
||||||
)
|
)
|
||||||
|
|
||||||
allMessages.push({
|
const msgObj: any = {
|
||||||
localId: allMessages.length + 1,
|
localId: allMessages.length + 1,
|
||||||
createTime: msg.createTime,
|
createTime: msg.createTime,
|
||||||
formattedTime: this.formatTimestamp(msg.createTime),
|
formattedTime: this.formatTimestamp(msg.createTime),
|
||||||
@@ -2944,7 +2964,17 @@ class ExportService {
|
|||||||
senderDisplayName,
|
senderDisplayName,
|
||||||
source,
|
source,
|
||||||
senderAvatarKey: msg.senderUsername
|
senderAvatarKey: msg.senderUsername
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 位置消息:附加结构化位置字段
|
||||||
|
if (msg.localType === 48) {
|
||||||
|
if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
|
||||||
|
if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
|
||||||
|
if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
|
||||||
|
if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
allMessages.push(msgObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class ImageDecryptService {
|
|||||||
if (finalExt === '.hevc') {
|
if (finalExt === '.hevc') {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||||
isThumb: this.isThumbnailPath(datPath)
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1833,21 +1833,24 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
// 提取 HEVC NALU 裸流
|
// 提取 HEVC NALU 裸流
|
||||||
const hevcData = this.extractHevcNalu(buffer)
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
if (!hevcData || hevcData.length < 100) {
|
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||||
return { data: buffer, isWxgf: true }
|
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||||
}
|
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||||
|
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||||
|
feedSize: feedData.length
|
||||||
|
})
|
||||||
|
|
||||||
// 尝试用 ffmpeg 转换
|
// 尝试用 ffmpeg 转换
|
||||||
try {
|
try {
|
||||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
const jpgData = await this.convertHevcToJpg(feedData)
|
||||||
if (jpgData && jpgData.length > 0) {
|
if (jpgData && jpgData.length > 0) {
|
||||||
return { data: jpgData, isWxgf: false }
|
return { data: jpgData, isWxgf: false }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ffmpeg 转换失败
|
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: hevcData, isWxgf: true }
|
return { data: feedData, isWxgf: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1914,50 +1917,92 @@ export class ImageDecryptService {
|
|||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
*/
|
*/
|
||||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
const ffmpeg = this.getFfmpegPath()
|
const ffmpeg = this.getFfmpegPath()
|
||||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||||
|
|
||||||
|
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||||
|
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||||
|
const ts = Date.now()
|
||||||
|
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||||
|
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(tmpInput, hevcData)
|
||||||
|
|
||||||
|
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||||
|
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||||
|
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||||
|
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
// 清理上一轮的输出
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
|
||||||
|
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
this.logError('ffmpeg 转换异常', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
const chunks: Buffer[] = []
|
|
||||||
const errChunks: Buffer[] = []
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
const proc = spawn(ffmpeg, [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner', '-loglevel', 'error',
|
||||||
'-loglevel', 'error',
|
...inputArgs,
|
||||||
'-f', 'hevc',
|
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||||
'-i', 'pipe:0',
|
]
|
||||||
'-vframes', '1',
|
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||||
'-q:v', '3',
|
|
||||||
'-f', 'mjpeg',
|
const proc = spawn(ffmpeg, args, {
|
||||||
'pipe:1'
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
||||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
proc.on('close', (code: number) => {
|
const timer = setTimeout(() => {
|
||||||
if (code === 0 && chunks.length > 0) {
|
proc.kill('SIGKILL')
|
||||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
this.logError(`ffmpeg [${label}] 超时(15s)`)
|
||||||
resolve(Buffer.concat(chunks))
|
resolve(null)
|
||||||
} else {
|
}, 15000)
|
||||||
const errMsg = Buffer.concat(errChunks).toString()
|
|
||||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (err: Error) => {
|
proc.on('close', (code: number) => {
|
||||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
clearTimeout(timer)
|
||||||
|
if (code === 0 && existsSync(tmpOutput)) {
|
||||||
|
try {
|
||||||
|
const jpgBuf = readFileSync(tmpOutput)
|
||||||
|
if (jpgBuf.length > 0) {
|
||||||
|
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
|
||||||
|
resolve(jpgBuf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString().trim()
|
||||||
|
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
|
||||||
resolve(null)
|
resolve(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdin.write(hevcData)
|
proc.on('error', (err: Error) => {
|
||||||
proc.stdin.end()
|
clearTimeout(timer)
|
||||||
|
this.logError(`ffmpeg [${label}] 进程错误`, err)
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -855,7 +855,7 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let found = null;
|
let found = null;
|
||||||
for (let upper = end; upper > start; upper--) {
|
for (let upper = end - 1; upper >= start; upper--) {
|
||||||
// 我就写 --
|
// 我就写 --
|
||||||
if (upper % 100000 === 0 && upper !== start) {
|
if (upper % 100000 === 0 && upper !== start) {
|
||||||
parentPort.postMessage({ type: 'progress', scanned: 100000 });
|
parentPort.postMessage({ type: 'progress', scanned: 100000 });
|
||||||
@@ -943,18 +943,15 @@ export class KeyService {
|
|||||||
cleanup()
|
cleanup()
|
||||||
resolve(msg.key)
|
resolve(msg.key)
|
||||||
} else if (msg.type === 'done') {
|
} else if (msg.type === 'done') {
|
||||||
// 单个 worker 跑完了没有找到
|
// 单个 worker 跑完了没有找到(计数统一在 exit 事件处理)
|
||||||
activeWorkers--
|
|
||||||
if (activeWorkers === 0 && !resolved) resolve(null)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('error', (err) => {
|
worker.on('error', (err) => {
|
||||||
console.error('Worker error:', err)
|
console.error('Worker error:', err)
|
||||||
activeWorkers--
|
|
||||||
if (activeWorkers === 0 && !resolved) resolve(null)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 统一在 exit 事件中做完成计数,避免 done/error + exit 双重递减
|
||||||
worker.on('exit', () => {
|
worker.on('exit', () => {
|
||||||
activeWorkers--
|
activeWorkers--
|
||||||
if (activeWorkers === 0 && !resolved) resolve(null)
|
if (activeWorkers === 0 && !resolved) resolve(null)
|
||||||
@@ -984,7 +981,7 @@ export class KeyService {
|
|||||||
|
|
||||||
onProgress?.('正在读取加密模板区块...')
|
onProgress?.('正在读取加密模板区块...')
|
||||||
const ciphertexts = this.getCiphertextsFromTemplate(templateFiles)
|
const ciphertexts = this.getCiphertextsFromTemplate(templateFiles)
|
||||||
if (ciphertexts.length === 0) return { success: false, error: '无法读取加密模板数据' }
|
if (ciphertexts.length < 2) return { success: false, error: '可用的加密样本不足(至少需要2个),请确认账号目录下有足够的模板图片' }
|
||||||
|
|
||||||
onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`)
|
onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`)
|
||||||
onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`)
|
onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -18,6 +19,16 @@ class VideoService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取数据库根目录
|
* 获取数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -69,7 +80,12 @@ class VideoService {
|
|||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
if (!wxid) return undefined
|
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
||||||
|
|
||||||
|
if (!wxid) {
|
||||||
|
this.log('queryVideoFileName: wxid 为空')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||||
if (cachePath) {
|
if (cachePath) {
|
||||||
@@ -84,20 +100,23 @@ class VideoService {
|
|||||||
for (const p of cacheDbPaths) {
|
for (const p of cacheDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试缓存 hardlink.db', { path: p })
|
||||||
const db = new Database(p, { readonly: true })
|
const db = new Database(p, { readonly: true })
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||||
WHERE md5 = ?
|
WHERE md5 = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||||
|
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
|
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +124,6 @@ class VideoService {
|
|||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
const wxidLower = wxid.toLowerCase()
|
const wxidLower = wxid.toLowerCase()
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
@@ -113,10 +131,8 @@ class VideoService {
|
|||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
const encryptedDbPaths: string[] = []
|
||||||
if (dbPathContainsWxid) {
|
if (dbPathContainsWxid) {
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
}
|
}
|
||||||
@@ -124,27 +140,29 @@ class VideoService {
|
|||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试加密 hardlink.db', { path: p })
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
const result = await wcdbService.execQuery('media', p, sql)
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||||
|
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.log('加密 hardlink.db 不存在', { path: p })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +188,16 @@ class VideoService {
|
|||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
if (!dbPath || !wxid || !videoMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
// 先尝试从数据库查询真正的视频文件名
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||||
|
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
@@ -184,50 +206,58 @@ class VideoService {
|
|||||||
|
|
||||||
let videoBaseDir: string
|
let videoBaseDir: string
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
this.log('getVideoInfo: videoBaseDir 不存在')
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
// 遍历年月目录查找视频文件
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
const allDirs = readdirSync(videoBaseDir)
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
const yearMonthDirs = allDirs
|
const yearMonthDirs = allDirs
|
||||||
.filter(dir => {
|
.filter(dir => {
|
||||||
const dirPath = join(videoBaseDir, dir)
|
const dirPath = join(videoBaseDir, dir)
|
||||||
return statSync(dirPath).isDirectory()
|
return statSync(dirPath).isDirectory()
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
this.log('扫描目录', { dirs: yearMonthDirs })
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
for (const yearMonth of yearMonthDirs) {
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
if (existsSync(videoPath)) {
|
||||||
|
this.log('找到视频', { videoPath })
|
||||||
|
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||||
return {
|
return {
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
videoUrl: videoPath,
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
exists: true
|
exists: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 没找到,列出第一个目录里的文件帮助排查
|
||||||
|
if (yearMonthDirs.length > 0) {
|
||||||
|
const firstDir = join(videoBaseDir, yearMonthDirs[0])
|
||||||
|
const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5)
|
||||||
|
this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` })
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3331,9 +3331,12 @@
|
|||||||
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
||||||
|
|
||||||
// 批量转写确认对话框
|
// 批量转写确认对话框
|
||||||
.batch-confirm-modal {
|
.batch-modal-content.batch-confirm-modal {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: visible;
|
||||||
|
|
||||||
.batch-modal-header {
|
.batch-modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3470,6 +3473,74 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-field {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.batch-concurrency-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3527,7 +3598,7 @@
|
|||||||
&.btn-primary,
|
&.btn-primary,
|
||||||
&.batch-transcribe-start-btn {
|
&.batch-transcribe-start-btn {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: #000;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
|||||||
@@ -345,6 +345,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
||||||
|
const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6)
|
||||||
|
const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false)
|
||||||
|
|
||||||
// 批量删除相关状态
|
// 批量删除相关状态
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -1662,29 +1664,44 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
for (let i = 0; i < images.length; i++) {
|
let completed = 0
|
||||||
const img = images[i]
|
const concurrency = batchDecryptConcurrency
|
||||||
|
|
||||||
|
const decryptOne = async (img: typeof images[0]) => {
|
||||||
try {
|
try {
|
||||||
const r = await window.electronAPI.image.decrypt({
|
const r = await window.electronAPI.image.decrypt({
|
||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: img.imageMd5,
|
imageMd5: img.imageMd5,
|
||||||
imageDatName: img.imageDatName,
|
imageDatName: img.imageDatName,
|
||||||
force: false
|
force: true
|
||||||
})
|
})
|
||||||
if (r?.success) successCount++
|
if (r?.success) successCount++
|
||||||
else failCount++
|
else failCount++
|
||||||
} catch {
|
} catch {
|
||||||
failCount++
|
failCount++
|
||||||
}
|
}
|
||||||
|
completed++
|
||||||
updateDecryptProgress(i + 1, images.length)
|
updateDecryptProgress(completed, images.length)
|
||||||
if (i % 5 === 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并发池:同时跑 concurrency 个任务
|
||||||
|
const pool: Promise<void>[] = []
|
||||||
|
for (const img of images) {
|
||||||
|
const p = decryptOne(img)
|
||||||
|
pool.push(p)
|
||||||
|
if (pool.length >= concurrency) {
|
||||||
|
await Promise.race(pool)
|
||||||
|
// 移除已完成的
|
||||||
|
for (let j = pool.length - 1; j >= 0; j--) {
|
||||||
|
const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)])
|
||||||
|
if (settled) pool.splice(j, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(pool)
|
||||||
|
|
||||||
finishDecrypt(successCount, failCount)
|
finishDecrypt(successCount, failCount)
|
||||||
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||||
|
|
||||||
const batchImageCountByDate = useMemo(() => {
|
const batchImageCountByDate = useMemo(() => {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
@@ -2623,6 +2640,39 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<span className="label">已选:</span>
|
<span className="label">已选:</span>
|
||||||
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="label">并发数:</span>
|
||||||
|
<div className="batch-concurrency-field">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
|
||||||
|
>
|
||||||
|
<span>{batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{showConcurrencyDropdown && (
|
||||||
|
<div className="batch-concurrency-dropdown">
|
||||||
|
{[
|
||||||
|
{ value: 1, label: '1(最慢,最稳)' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 6, label: '6(推荐)' },
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 20, label: '20(最快,可能卡顿)' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`batch-concurrency-option ${batchDecryptConcurrency === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => { setBatchDecryptConcurrency(opt.value); setShowConcurrencyDropdown(false) }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-warning">
|
<div className="batch-warning">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
|
|||||||
Reference in New Issue
Block a user