initial2
This commit is contained in:
@@ -44,7 +44,6 @@
|
|||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
"@vue/tsconfig": "^0.5.0",
|
"@vue/tsconfig": "^0.5.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"cesium": "^1.129.0",
|
|
||||||
"eslint": "^8.49.0",
|
"eslint": "^8.49.0",
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
"imagemin": "^9.0.0",
|
"imagemin": "^9.0.0",
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
"terser": "^5.37.0",
|
"terser": "^5.37.0",
|
||||||
"typescript": "~5.3.0",
|
"typescript": "~5.3.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-image-optimizer": "^1.1.8",
|
"vite-plugin-image-optimizer": "^1.1.8",
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- HomeView.vue -->
|
<!-- HomeView.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive } from 'vue'
|
import { ref, onMounted, reactive, watch } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -12,7 +12,144 @@ interface Album {
|
|||||||
authors?: string[]
|
authors?: string[]
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
// 持久化存储键名
|
||||||
|
const PERSISTENCE_KEY = 'homeViewState'
|
||||||
|
const SCROLL_POSITION_KEY = 'homeViewScrollPosition'
|
||||||
|
// 定义持久化状态接口
|
||||||
|
interface HomeViewState {
|
||||||
|
searchKeyword: string
|
||||||
|
showSearchResults: boolean
|
||||||
|
searchResults: Album[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDB 配置
|
||||||
|
const IDB_CONFIG = {
|
||||||
|
dbName: 'MangaImageCache',
|
||||||
|
storeName: 'imageStore',
|
||||||
|
version: 1,
|
||||||
|
cacheExpireDays: 7 // 缓存有效期:7天
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 IDB 数据库
|
||||||
|
const initIDB = (): Promise<IDBDatabase> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(IDB_CONFIG.dbName, IDB_CONFIG.version)
|
||||||
|
|
||||||
|
// 数据库升级/创建
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result
|
||||||
|
// 创建存储对象(键路径:albumId,唯一标识)
|
||||||
|
if (!db.objectStoreNames.contains(IDB_CONFIG.storeName)) {
|
||||||
|
db.createObjectStore(IDB_CONFIG.storeName, { keyPath: 'albumId' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开成功
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result
|
||||||
|
resolve(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开失败
|
||||||
|
request.onerror = (event) => {
|
||||||
|
reject((event.target as IDBOpenDBRequest).error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 IDB 读取图片缓存
|
||||||
|
const getImageFromIDB = async (albumId: string): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const db = await initIDB()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction(IDB_CONFIG.storeName, 'readonly')
|
||||||
|
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||||
|
const request = store.get(albumId)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cacheData = request.result
|
||||||
|
if (!cacheData) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存是否过期
|
||||||
|
const now = Date.now()
|
||||||
|
const expireTime = cacheData.timestamp + IDB_CONFIG.cacheExpireDays * 24 * 60 * 60 * 1000
|
||||||
|
if (now > expireTime) {
|
||||||
|
// 过期:删除旧缓存
|
||||||
|
const deleteRequest = store.delete(albumId)
|
||||||
|
deleteRequest.onsuccess = () => resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存有效:返回图片数据
|
||||||
|
resolve(cacheData.imageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => resolve(null)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将图片缓存写入 IDB
|
||||||
|
const saveImageToIDB = async (albumId: string, imageData: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const db = await initIDB()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction(IDB_CONFIG.storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||||
|
// 存储数据:包含唯一标识、图片数据、时间戳
|
||||||
|
const request = store.put({
|
||||||
|
albumId,
|
||||||
|
imageData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(true)
|
||||||
|
request.onerror = () => resolve(false)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理所有过期缓存
|
||||||
|
const cleanExpiredCache = async () => {
|
||||||
|
try {
|
||||||
|
const db = await initIDB()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction(IDB_CONFIG.storeName, 'readwrite')
|
||||||
|
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||||
|
const request = store.openCursor()
|
||||||
|
const now = Date.now()
|
||||||
|
let deletedCount = 0
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||||
|
if (cursor) {
|
||||||
|
const cacheData = cursor.value
|
||||||
|
const expireTime = cacheData.timestamp + IDB_CONFIG.cacheExpireDays * 24 * 60 * 60 * 1000
|
||||||
|
if (now > expireTime) {
|
||||||
|
cursor.delete()
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
resolve(deletedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => resolve(0)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面核心逻辑
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const albums = ref<Album[]>([])
|
const albums = ref<Album[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -26,16 +163,27 @@ const searchResults = ref<Album[]>([])
|
|||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
const showSearchResults = ref(false)
|
const showSearchResults = ref(false)
|
||||||
|
|
||||||
// 存储已解码的封面图片
|
// 临时状态存储(页面生命周期内)
|
||||||
const coverImages = reactive<Record<string, string>>({})
|
const coverImages = reactive<Record<string, string>>({})
|
||||||
// 记录已处理的专辑,避免重复处理
|
|
||||||
const processedAlbums = new Set<string>()
|
const processedAlbums = new Set<string>()
|
||||||
// 记录已解码完成的专辑,用于控制显示
|
|
||||||
const decodedAlbums = reactive<Record<string, boolean>>({})
|
const decodedAlbums = reactive<Record<string, boolean>>({})
|
||||||
|
const decodeQueue = ref<Album[]>([])
|
||||||
|
const isProcessingQueue = ref(false)
|
||||||
|
|
||||||
|
// 图片加载重试配置
|
||||||
|
const retryConfig = {
|
||||||
|
maxRetryCount: 3,
|
||||||
|
retryInterval: 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监控搜索关键词变化(无日志)
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
// 仅状态响应,无日志输出
|
||||||
|
})
|
||||||
|
|
||||||
// 获取推荐漫画
|
// 获取推荐漫画
|
||||||
const fetchRecommendedManga = async () => {
|
const fetchRecommendedManga = async () => {
|
||||||
if (loading.value || !hasMore.value) return
|
if (loading.value || !hasMore.value || showSearchResults.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -47,26 +195,27 @@ const fetchRecommendedManga = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加新专辑到列表
|
|
||||||
albums.value = [...albums.value, ...newAlbums]
|
albums.value = [...albums.value, ...newAlbums]
|
||||||
|
|
||||||
// 为新专辑解码封面
|
if (!showSearchResults.value) {
|
||||||
newAlbums.forEach(album => {
|
newAlbums.forEach(album => {
|
||||||
if (!processedAlbums.has(album.album_id)) {
|
if (!processedAlbums.has(album.album_id)) {
|
||||||
decodeAndCacheCover(album)
|
decodeQueue.value.push(album)
|
||||||
processedAlbums.add(album.album_id)
|
processedAlbums.add(album.album_id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
processDecodeQueue()
|
||||||
|
}
|
||||||
|
|
||||||
page.value++
|
page.value++
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取推荐漫画失败:', error)
|
// 静默失败,不输出日志
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码图片
|
// 解码图片(核心逻辑保留)
|
||||||
const decodeImage = (imgSrc: HTMLImageElement, num: number): string => {
|
const decodeImage = (imgSrc: HTMLImageElement, num: number): string => {
|
||||||
if (num === 0) {
|
if (num === 0) {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -132,53 +281,99 @@ const decodeImage = (imgSrc: HTMLImageElement, num: number): string => {
|
|||||||
return canvas.toDataURL()
|
return canvas.toDataURL()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码并缓存封面图片
|
// 顺序处理加载队列(搜索状态正常处理)
|
||||||
const decodeAndCacheCover = (album: Album) => {
|
const processDecodeQueue = async () => {
|
||||||
// 如果没有图片URL,返回默认图片
|
if (isProcessingQueue.value || decodeQueue.value.length === 0) {
|
||||||
if (!album.image_urls || album.image_urls.length === 0) {
|
|
||||||
decodedAlbums[album.album_id] = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = new Image()
|
isProcessingQueue.value = true
|
||||||
img.crossOrigin = 'anonymous'
|
|
||||||
img.src = album.image_urls[0]
|
|
||||||
|
|
||||||
img.onload = () => {
|
try {
|
||||||
// 使用第一张图片的解码参数
|
while (decodeQueue.value.length > 0) {
|
||||||
const num = album.nums && album.nums.length > 0 ? album.nums[0] : 0
|
const album = decodeQueue.value[0]
|
||||||
if (num !== 0) {
|
await decodeAndCacheCover(album)
|
||||||
const decodedImage = decodeImage(img, num)
|
decodeQueue.value.shift()
|
||||||
coverImages[album.album_id] = decodedImage
|
|
||||||
} else {
|
|
||||||
coverImages[album.album_id] = img.src
|
|
||||||
}
|
}
|
||||||
// 标记解码完成
|
} finally {
|
||||||
decodedAlbums[album.album_id] = true
|
isProcessingQueue.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
img.onerror = () => {
|
// 解码并缓存封面(整合 IDB 缓存)
|
||||||
// 加载失败时使用原始URL
|
const decodeAndCacheCover = (album: Album, currentRetry = 0): Promise<void> => {
|
||||||
coverImages[album.album_id] = album.image_urls[0]
|
return new Promise(async (resolve) => {
|
||||||
// 标记解码完成(即使失败也标记完成)
|
// 先从 IDB 读取缓存
|
||||||
decodedAlbums[album.album_id] = true
|
const cachedImage = await getImageFromIDB(album.album_id)
|
||||||
}
|
if (cachedImage) {
|
||||||
|
// 缓存命中:直接使用
|
||||||
|
coverImages[album.album_id] = cachedImage
|
||||||
|
decodedAlbums[album.album_id] = true
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存未命中:从网络加载
|
||||||
|
if (!album.image_urls || album.image_urls.length === 0) {
|
||||||
|
decodedAlbums[album.album_id] = true
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgUrl = album.image_urls[0]
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
|
||||||
|
// 超时处理
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
img.src = ''
|
||||||
|
handleLoadFail('timeout')
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
// 加载成功:解码 + 写入 IDB
|
||||||
|
img.onload = () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
const num = album.nums && album.nums.length > 0 ? album.nums[0] : 0
|
||||||
|
let imageData = ''
|
||||||
|
|
||||||
|
if (num !== 0) {
|
||||||
|
imageData = decodeImage(img, num)
|
||||||
|
} else {
|
||||||
|
imageData = img.src
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 IDB 缓存(不阻塞主线程)
|
||||||
|
saveImageToIDB(album.album_id, imageData)
|
||||||
|
// 更新临时存储
|
||||||
|
coverImages[album.album_id] = imageData
|
||||||
|
decodedAlbums[album.album_id] = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载失败重试
|
||||||
|
const handleLoadFail = (errorType: 'error' | 'timeout') => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (currentRetry < retryConfig.maxRetryCount) {
|
||||||
|
const nextRetry = currentRetry + 1
|
||||||
|
setTimeout(() => {
|
||||||
|
decodeAndCacheCover(album, nextRetry).then(resolve)
|
||||||
|
}, retryConfig.retryInterval)
|
||||||
|
} else {
|
||||||
|
// 重试失败:使用原始 URL 兜底(不缓存)
|
||||||
|
coverImages[album.album_id] = album.image_urls[0] || ''
|
||||||
|
decodedAlbums[album.album_id] = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => handleLoadFail('error')
|
||||||
|
img.src = imgUrl
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取封面图片URL
|
// 获取封面图片URL
|
||||||
const getCoverImageUrl = (album: Album): string => {
|
const getCoverImageUrl = (album: Album): string => {
|
||||||
// 如果已经解码过,直接返回解码后的图片
|
return coverImages[album.album_id] || album.image_urls?.[0] || ''
|
||||||
if (coverImages[album.album_id]) {
|
|
||||||
return coverImages[album.album_id]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有原始URL,返回原始URL
|
|
||||||
if (album.image_urls && album.image_urls.length > 0) {
|
|
||||||
return album.image_urls[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 否则返回空
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查专辑封面是否已解码完成
|
// 检查专辑封面是否已解码完成
|
||||||
@@ -191,11 +386,13 @@ const goToManga = (albumId: string) => {
|
|||||||
router.push(`/manga/${albumId}`)
|
router.push(`/manga/${albumId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索功能
|
// 修改搜索漫画函数,添加状态保存
|
||||||
const searchManga = async () => {
|
const searchManga = async () => {
|
||||||
if (!searchKeyword.value.trim()) {
|
const keyword = searchKeyword.value.trim()
|
||||||
|
if (!keyword) {
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
showSearchResults.value = false
|
showSearchResults.value = false
|
||||||
|
saveState() // 保存状态
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,40 +400,57 @@ const searchManga = async () => {
|
|||||||
showSearchResults.value = true
|
showSearchResults.value = true
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/manga/search`, {
|
const response = await axios.get(`/api/manga/search`, {
|
||||||
params: {
|
params: { keyword, page: 1, type: 0 }
|
||||||
keyword: searchKeyword.value,
|
|
||||||
page: 1,
|
|
||||||
type: 0
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
searchResults.value = response.data
|
searchResults.value = response.data
|
||||||
|
|
||||||
// 为搜索结果解码封面
|
// 清空队列,处理搜索结果
|
||||||
|
decodeQueue.value = []
|
||||||
searchResults.value.forEach(album => {
|
searchResults.value.forEach(album => {
|
||||||
|
decodedAlbums[album.album_id] = false
|
||||||
if (!processedAlbums.has(album.album_id)) {
|
if (!processedAlbums.has(album.album_id)) {
|
||||||
decodeAndCacheCover(album)
|
decodeQueue.value.push(album)
|
||||||
processedAlbums.add(album.album_id)
|
processedAlbums.add(album.album_id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await processDecodeQueue()
|
||||||
|
saveState() // 保存状态
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索漫画失败:', error)
|
// 静默失败
|
||||||
} finally {
|
} finally {
|
||||||
searching.value = false
|
searching.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理回车键搜索
|
// 修改清空搜索函数
|
||||||
const handleSearchKey = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
searchManga()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空搜索
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
searchKeyword.value = ''
|
searchKeyword.value = ''
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
showSearchResults.value = false
|
showSearchResults.value = false
|
||||||
|
decodeQueue.value = albums.value.filter(album => !decodedAlbums[album.album_id])
|
||||||
|
processDecodeQueue()
|
||||||
|
saveState() // 保存状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索键盘事件
|
||||||
|
const handleSearchKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const keyword = searchKeyword.value.trim()
|
||||||
|
if (/^\d+$/.test(keyword)) {
|
||||||
|
router.push(`/manga/${keyword}`)
|
||||||
|
clearSearch()
|
||||||
|
} else {
|
||||||
|
searchManga()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动触发搜索
|
||||||
|
const triggerSearch = () => {
|
||||||
|
searchManga()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置无限滚动观察器
|
// 设置无限滚动观察器
|
||||||
@@ -246,9 +460,7 @@ const setupInfiniteScroll = () => {
|
|||||||
if (target.isIntersecting && !loading.value && hasMore.value && !showSearchResults.value) {
|
if (target.isIntersecting && !loading.value && hasMore.value && !showSearchResults.value) {
|
||||||
fetchRecommendedManga()
|
fetchRecommendedManga()
|
||||||
}
|
}
|
||||||
}, {
|
}, { rootMargin: '100px' })
|
||||||
rootMargin: '100px' // 提前100px触发加载
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadMoreTrigger = document.getElementById('load-more-trigger')
|
const loadMoreTrigger = document.getElementById('load-more-trigger')
|
||||||
if (loadMoreTrigger && observer.value) {
|
if (loadMoreTrigger && observer.value) {
|
||||||
@@ -256,15 +468,103 @@ const setupInfiniteScroll = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时初始化
|
// 修改组件挂载初始化
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
// 尝试恢复状态
|
||||||
|
const savedState = restoreState()
|
||||||
|
if (savedState) {
|
||||||
|
searchKeyword.value = savedState.searchKeyword
|
||||||
|
showSearchResults.value = savedState.showSearchResults
|
||||||
|
searchResults.value = savedState.searchResults
|
||||||
|
|
||||||
|
// 如果有搜索结果,处理搜索结果的图片加载
|
||||||
|
if (showSearchResults.value && searchResults.value.length > 0) {
|
||||||
|
decodeQueue.value = []
|
||||||
|
searchResults.value.forEach(album => {
|
||||||
|
decodedAlbums[album.album_id] = false
|
||||||
|
if (!processedAlbums.has(album.album_id)) {
|
||||||
|
decodeQueue.value.push(album)
|
||||||
|
processedAlbums.add(album.album_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
processDecodeQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期缓存(后台执行,不阻塞)
|
||||||
|
await cleanExpiredCache()
|
||||||
|
|
||||||
fetchRecommendedManga().then(() => {
|
fetchRecommendedManga().then(() => {
|
||||||
// 等待DOM更新后设置无限滚动
|
setupInfiniteScroll()
|
||||||
setTimeout(setupInfiniteScroll, 0)
|
processDecodeQueue()
|
||||||
|
// 恢复滚动位置
|
||||||
|
if (!savedState || !savedState.showSearchResults) {
|
||||||
|
restoreScrollPosition()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
|
||||||
|
|
||||||
|
// 空函数占位(兼容旧逻辑)
|
||||||
|
const restoreState = (): HomeViewState | null => {
|
||||||
|
try {
|
||||||
|
const savedState = localStorage.getItem(PERSISTENCE_KEY)
|
||||||
|
if (savedState) {
|
||||||
|
return JSON.parse(savedState)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('恢复状态失败:', e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// 保存状态到 localStorage
|
||||||
|
const saveState = () => {
|
||||||
|
try {
|
||||||
|
const state: HomeViewState = {
|
||||||
|
searchKeyword: searchKeyword.value,
|
||||||
|
showSearchResults: showSearchResults.value,
|
||||||
|
searchResults: searchResults.value
|
||||||
|
}
|
||||||
|
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存状态失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 保存滚动位置
|
||||||
|
const saveScrollPosition = () => {
|
||||||
|
try {
|
||||||
|
const scrollPosition = {
|
||||||
|
x: window.scrollX || window.pageXOffset,
|
||||||
|
y: window.scrollY || window.pageYOffset
|
||||||
|
}
|
||||||
|
localStorage.setItem(SCROLL_POSITION_KEY, JSON.stringify(scrollPosition))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存滚动位置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 恢复滚动位置
|
||||||
|
const restoreScrollPosition = () => {
|
||||||
|
try {
|
||||||
|
const savedPosition = localStorage.getItem(SCROLL_POSITION_KEY)
|
||||||
|
if (savedPosition) {
|
||||||
|
const { x, y } = JSON.parse(savedPosition)
|
||||||
|
// 使用 nextTick 确保 DOM 更新后再滚动
|
||||||
|
nextTick(() => {
|
||||||
|
window.scrollTo(x, y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('恢复滚动位置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除保存的状态
|
||||||
|
const clearSavedState = () => {
|
||||||
|
localStorage.removeItem(PERSISTENCE_KEY)
|
||||||
|
localStorage.removeItem(SCROLL_POSITION_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="home-view">
|
<div class="home-view">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -279,7 +579,6 @@ onMounted(() => {
|
|||||||
@click="goToManga(album.album_id)"
|
@click="goToManga(album.album_id)"
|
||||||
>
|
>
|
||||||
<div class="manga-cover">
|
<div class="manga-cover">
|
||||||
<!-- 只有解码完成后才显示图片 -->
|
|
||||||
<img
|
<img
|
||||||
v-if="isAlbumDecoded(album) && getCoverImageUrl(album)"
|
v-if="isAlbumDecoded(album) && getCoverImageUrl(album)"
|
||||||
:src="getCoverImageUrl(album)"
|
:src="getCoverImageUrl(album)"
|
||||||
@@ -320,7 +619,6 @@ onMounted(() => {
|
|||||||
<div v-else-if="!hasMore" class="no-more">没有更多了</div>
|
<div v-else-if="!hasMore" class="no-more">没有更多了</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<div class="search-input-container">
|
<div class="search-input-container">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Manga.vue -->
|
<!-- Manga.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
@@ -8,7 +8,7 @@ import router from "@/router";
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
// 漫画专辑数据
|
// 漫画专辑数据接口
|
||||||
interface AlbumData {
|
interface AlbumData {
|
||||||
album_id: string
|
album_id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -16,9 +16,10 @@ interface AlbumData {
|
|||||||
nums: number[]
|
nums: number[]
|
||||||
authors?: string[]
|
authors?: string[]
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
readIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 漫画图片列表(从 jm.vue 中获取)
|
// 漫画图片列表
|
||||||
const mangaImages = ref<string[]>([])
|
const mangaImages = ref<string[]>([])
|
||||||
const mangaNums = ref<number[]>([])
|
const mangaNums = ref<number[]>([])
|
||||||
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
|
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
|
||||||
@@ -28,25 +29,154 @@ const showMenu = ref(false)
|
|||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const showDrawer = ref(false)
|
const showDrawer = ref(false)
|
||||||
let currentImageIndex = ref(0) // 当前显示的图片索引
|
let currentImageIndex = ref(0) // 当前显示的图片索引
|
||||||
const imageStates = ref<Array<{ scale: number; translateX: number; translateY: number }>>([])
|
const imageStates = ref<Array<{
|
||||||
|
scale: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
startX: number; // 拖拽起始X
|
||||||
|
startY: number; // 拖拽起始Y
|
||||||
|
isDragging: boolean; // 是否正在拖拽
|
||||||
|
}>>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const canvasImages = ref<string[]>([]) // 解码后的图片
|
const canvasImages = ref<string[]>([]) // 解码后的图片
|
||||||
const abortLoading = ref(false) // 取消加载标志
|
const abortLoading = ref(false) // 取消加载标志
|
||||||
|
|
||||||
// 计算是否应该隐藏头部(用于样式调整)
|
// 图片加载状态跟踪
|
||||||
|
const imageLoadStates = ref<Array<'loading' | 'loaded' | 'error'>>([])
|
||||||
|
// 重试配置 - 每次重试间隔时间(毫秒)
|
||||||
|
const retryInterval = 3000
|
||||||
|
|
||||||
|
// 计算是否应该隐藏头部
|
||||||
const shouldHideHeader = computed(() => {
|
const shouldHideHeader = computed(() => {
|
||||||
return route.meta?.hideHeader === true
|
return route.meta?.hideHeader === true
|
||||||
})
|
})
|
||||||
// 在现有代码中添加新的函数
|
|
||||||
|
// 生成未加载占位图片
|
||||||
|
const generatePlaceholderImage = (width: number = 300, height: number = 400): string => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return ''
|
||||||
|
|
||||||
|
// 背景色
|
||||||
|
ctx.fillStyle = '#f5f5f5'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
// 网格线条
|
||||||
|
ctx.strokeStyle = '#e0e0e0'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const y = (i + 1) * (height / 6)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(width * 0.1, y)
|
||||||
|
ctx.lineTo(width * 0.9, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未加载文字
|
||||||
|
ctx.fillStyle = '#999'
|
||||||
|
ctx.font = '14px Arial'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText('加载中...', width / 2, height / 2)
|
||||||
|
|
||||||
|
return canvas.toDataURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB 缓存实现
|
||||||
|
const IDB_STORE_NAME = 'manga_image_cache'
|
||||||
|
const IDB_VERSION = 1
|
||||||
|
|
||||||
|
// 初始化IndexedDB
|
||||||
|
const initIDB = (): Promise<IDBDatabase> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('MangaReaderCache', IDB_VERSION)
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result
|
||||||
|
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
||||||
|
db.createObjectStore(IDB_STORE_NAME, { keyPath: 'url' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
resolve((event.target as IDBOpenDBRequest).result)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
console.error('IndexedDB初始化失败:', (event.target as IDBOpenDBRequest).error)
|
||||||
|
reject((event.target as IDBOpenDBRequest).error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从缓存获取图片
|
||||||
|
const getImageFromCache = (url: string): Promise<string | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
initIDB().then(db => {
|
||||||
|
const transaction = db.transaction(IDB_STORE_NAME, 'readonly')
|
||||||
|
const store = transaction.objectStore(IDB_STORE_NAME)
|
||||||
|
const request = store.get(url)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result
|
||||||
|
// 检查缓存是否过期(7天有效期)
|
||||||
|
if (data && data.timestamp && (Date.now() - data.timestamp) < 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
resolve(data.dataUrl)
|
||||||
|
} else {
|
||||||
|
// 过期则删除缓存
|
||||||
|
if (data) {
|
||||||
|
const deleteTx = db.transaction(IDB_STORE_NAME, 'readwrite')
|
||||||
|
deleteTx.objectStore(IDB_STORE_NAME).delete(url)
|
||||||
|
}
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('从缓存获取图片失败:', request.error)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存图片到缓存
|
||||||
|
const saveImageToCache = (url: string, dataUrl: string) => {
|
||||||
|
initIDB().then(db => {
|
||||||
|
const transaction = db.transaction(IDB_STORE_NAME, 'readwrite')
|
||||||
|
const store = transaction.objectStore(IDB_STORE_NAME)
|
||||||
|
store.put({
|
||||||
|
url,
|
||||||
|
dataUrl,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log(`图片已缓存: ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.onerror = () => {
|
||||||
|
console.error('图片缓存失败:', transaction.error)
|
||||||
|
}
|
||||||
|
}).catch(() => { /* 忽略缓存失败 */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上报阅读进度
|
||||||
const reportReadManga = async (mangaId: string, index: number) => {
|
const reportReadManga = async (mangaId: string, index: number) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/manga/read', {}, {
|
await axios.post('/api/manga/read', {}, {
|
||||||
headers: {
|
headers: {
|
||||||
Token: token || '',
|
Token: token || '',
|
||||||
mangaId: mangaId,
|
mangaId: mangaId,
|
||||||
index: index.toString()
|
index: index + 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
console.log('上报成功' + index.toString())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上报阅读进度失败:', error);
|
console.error('上报阅读进度失败:', error);
|
||||||
}
|
}
|
||||||
@@ -57,6 +187,61 @@ const currentPageInfo = computed(() => {
|
|||||||
return `第 ${currentImageIndex.value + 1} / ${mangaImages.value.length} 页`
|
return `第 ${currentImageIndex.value + 1} / ${mangaImages.value.length} 页`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 图片加载函数(支持缓存和无限重试)
|
||||||
|
const loadImageWithRetry = (url: string, num: number, index: number): Promise<void> => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
// 取消加载时直接返回
|
||||||
|
if (abortLoading.value) return resolve()
|
||||||
|
|
||||||
|
// 先尝试从缓存加载
|
||||||
|
getImageFromCache(url).then(cachedDataUrl => {
|
||||||
|
if (cachedDataUrl) {
|
||||||
|
console.log(`使用缓存图片: 索引${index} - ${url}`)
|
||||||
|
canvasImages.value[index] = cachedDataUrl
|
||||||
|
imageLoadStates.value[index] = 'loaded'
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存未命中,从网络加载
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.src = url
|
||||||
|
|
||||||
|
// 加载成功处理
|
||||||
|
img.onload = () => {
|
||||||
|
if (abortLoading.value) return resolve()
|
||||||
|
|
||||||
|
let dataUrl: string
|
||||||
|
// 按原逻辑解码图片
|
||||||
|
if (num !== 0) {
|
||||||
|
const decodedCanvas = decodeImage(img, num)
|
||||||
|
dataUrl = decodedCanvas.toDataURL()
|
||||||
|
} else {
|
||||||
|
dataUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasImages.value[index] = dataUrl
|
||||||
|
imageLoadStates.value[index] = 'loaded'
|
||||||
|
// 保存到缓存
|
||||||
|
saveImageToCache(url, dataUrl)
|
||||||
|
console.log(`图片加载成功: 索引${index} - ${url}`)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载失败处理 - 无限重试
|
||||||
|
img.onerror = () => {
|
||||||
|
if (abortLoading.value) return resolve()
|
||||||
|
console.error(`图片加载失败,将在${retryInterval}ms后重试: 索引${index} - ${url}`)
|
||||||
|
imageLoadStates.value[index] = 'loading'
|
||||||
|
// 延迟后递归重试
|
||||||
|
setTimeout(() => {
|
||||||
|
loadImageWithRetry(url, num, index).then(resolve)
|
||||||
|
}, retryInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取专辑数据
|
// 获取专辑数据
|
||||||
const fetchAlbum = async (id: string) => {
|
const fetchAlbum = async (id: string) => {
|
||||||
console.log('尝试获取专辑数据,ID:', id)
|
console.log('尝试获取专辑数据,ID:', id)
|
||||||
@@ -77,7 +262,6 @@ const fetchAlbum = async (id: string) => {
|
|||||||
console.log('收到响应:', response)
|
console.log('收到响应:', response)
|
||||||
|
|
||||||
let data: AlbumData
|
let data: AlbumData
|
||||||
|
|
||||||
if (response.status === 307 || response.status === 302) {
|
if (response.status === 307 || response.status === 302) {
|
||||||
const newUrl = new URL(response.headers.location, response.config.url).href
|
const newUrl = new URL(response.headers.location, response.config.url).href
|
||||||
const redirectResponse = await axios.get(newUrl)
|
const redirectResponse = await axios.get(newUrl)
|
||||||
@@ -93,85 +277,114 @@ const fetchAlbum = async (id: string) => {
|
|||||||
authors: data.authors || [],
|
authors: data.authors || [],
|
||||||
tags: data.tags || []
|
tags: data.tags || []
|
||||||
}
|
}
|
||||||
|
if (data.album_id) {
|
||||||
|
checkIsLoved(data.album_id)
|
||||||
|
}
|
||||||
// 设置图片 URL 和解码参数
|
// 设置图片 URL 和解码参数
|
||||||
mangaImages.value = data.image_urls || []
|
mangaImages.value = data.image_urls || []
|
||||||
mangaNums.value = data.nums || []
|
mangaNums.value = data.nums || []
|
||||||
|
|
||||||
// 初始化图片状态
|
// 初始化图片状态、画布数组、加载状态
|
||||||
imageStates.value = (data.image_urls || []).map(() => ({
|
imageStates.value = (data.image_urls || []).map(() => ({
|
||||||
scale: 1,
|
scale: 1,
|
||||||
translateX: 0,
|
translateX: 0,
|
||||||
translateY: 0
|
translateY: 0,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isDragging: false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 初始化画布图片数组
|
|
||||||
canvasImages.value = new Array((data.image_urls || []).length)
|
canvasImages.value = new Array((data.image_urls || []).length)
|
||||||
|
imageLoadStates.value = (data.image_urls || []).map(() => 'loading')
|
||||||
|
|
||||||
// 开始加载图片
|
// 设置当前阅读进度并跳转
|
||||||
|
const targetIndex = data.readIndex || 0
|
||||||
|
currentImageIndex.value = targetIndex
|
||||||
|
await nextTick()
|
||||||
|
// 修复跳转逻辑,直接跳转到目标索引
|
||||||
|
scrollToImage(targetIndex)
|
||||||
|
console.log('已跳转到上次阅读位置,索引:', targetIndex)
|
||||||
|
|
||||||
|
// 关键修改:删除优先加载,直接全量加载所有图片(带缓存支持)
|
||||||
if (data.image_urls && data.nums) {
|
if (data.image_urls && data.nums) {
|
||||||
loadImagesInBatches(data.image_urls, data.nums)
|
await loadAllImages(data.image_urls, data.nums)
|
||||||
} else {
|
} else {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
scrollToImage(data.readIndex)
|
|
||||||
currentImageIndex.value = data.readIndex
|
|
||||||
console.log(currentImageIndex.value)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载专辑失败', error)
|
console.error('加载专辑失败', error)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分批加载图片(每次加载4张)
|
// 全量加载所有图片(删除优先加载逻辑,按顺序加载所有)
|
||||||
const loadImagesInBatches = async (urls: string[], nums: number[]) => {
|
const loadAllImages = async (urls: string[], nums: number[]) => {
|
||||||
const batchSize = 4 // 每批加载4张图片
|
abortLoading.value = false
|
||||||
let currentIndex = 0
|
const batchSize = 3 // 保留小批量加载避免请求过于集中
|
||||||
abortLoading.value = false // 开始新加载任务前重置标志
|
|
||||||
|
|
||||||
while (currentIndex < urls.length && !abortLoading.value) {
|
// 按顺序分批次加载所有图片(从第0页开始)
|
||||||
const batchUrls = urls.slice(currentIndex, currentIndex + batchSize)
|
for (let i = 0; i < urls.length; i += batchSize) {
|
||||||
const batchNums = nums.slice(currentIndex, currentIndex + batchSize)
|
if (abortLoading.value) break // 取消加载时退出
|
||||||
|
|
||||||
const loadPromises = batchUrls.map((url, index) => {
|
const end = Math.min(i + batchSize, urls.length)
|
||||||
return new Promise<void>((resolve) => {
|
const batchPromises = []
|
||||||
if (abortLoading.value) return resolve()
|
|
||||||
|
|
||||||
const img = new Image()
|
// 加载当前批次的图片(包含前3页)
|
||||||
img.crossOrigin = 'anonymous'
|
for (let j = i; j < end; j++) {
|
||||||
img.src = url
|
batchPromises.push(loadImageWithRetry(urls[j], nums[j], j))
|
||||||
|
}
|
||||||
|
|
||||||
img.onload = () => {
|
await Promise.all(batchPromises)
|
||||||
if (abortLoading.value) return resolve()
|
console.log(`已加载批次: ${i}~${end-1}`)
|
||||||
|
|
||||||
if (batchNums[index] !== 0) {
|
|
||||||
const decodedCanvas = decodeImage(img, batchNums[index])
|
|
||||||
const base64 = decodedCanvas.toDataURL()
|
|
||||||
canvasImages.value[currentIndex + index] = base64
|
|
||||||
} else {
|
|
||||||
canvasImages.value[currentIndex + index] = url
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
if (abortLoading.value) return resolve()
|
|
||||||
console.error(`图片加载失败: ${url}`)
|
|
||||||
// 加载失败时使用原始 URL
|
|
||||||
canvasImages.value[currentIndex + index] = url
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(loadPromises)
|
|
||||||
currentIndex += batchSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
console.log('所有图片加载完成')
|
||||||
}
|
}
|
||||||
|
|
||||||
// JS 版 decodeImage(等效 Java 方法)
|
// 收藏相关功能
|
||||||
|
const isLoved = ref(false)
|
||||||
|
const checkIsLoved = async (mangaId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/manga/isLove?mangaId=${mangaId}`, {
|
||||||
|
headers: {
|
||||||
|
Token: token || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
isLoved.value = response.data === '已收藏'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查收藏状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLove = async () => {
|
||||||
|
if (!albumInfo.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLoved.value) {
|
||||||
|
await axios.delete(`/api/manga/love?mangaId=${albumInfo.value.album_id}`, {
|
||||||
|
headers: {
|
||||||
|
Token: token || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
isLoved.value = false
|
||||||
|
} else {
|
||||||
|
await axios.post(`/api/manga/love`, {}, {
|
||||||
|
params: {
|
||||||
|
mangaId: albumInfo.value.album_id,
|
||||||
|
mangaName: albumInfo.value.name
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Token: token || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
isLoved.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('收藏操作失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片解码函数
|
||||||
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
|
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
|
||||||
if (num === 0) {
|
if (num === 0) {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -247,7 +460,7 @@ const toggleMenu = () => {
|
|||||||
showMenu.value = !showMenu.value
|
showMenu.value = !showMenu.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进入全屏模式
|
// 全屏模式切换
|
||||||
const enterFullscreen = () => {
|
const enterFullscreen = () => {
|
||||||
const element = document.documentElement
|
const element = document.documentElement
|
||||||
if (element.requestFullscreen) {
|
if (element.requestFullscreen) {
|
||||||
@@ -256,7 +469,6 @@ const enterFullscreen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出全屏模式
|
|
||||||
const exitFullscreen = () => {
|
const exitFullscreen = () => {
|
||||||
if (document.exitFullscreen) {
|
if (document.exitFullscreen) {
|
||||||
document.exitFullscreen()
|
document.exitFullscreen()
|
||||||
@@ -264,16 +476,19 @@ const exitFullscreen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置所有图片的缩放
|
// 重置所有图片的缩放和位置
|
||||||
const resetAllImages = () => {
|
const resetAllImages = () => {
|
||||||
imageStates.value = imageStates.value.map(() => ({
|
imageStates.value = imageStates.value.map(() => ({
|
||||||
scale: 1,
|
scale: 1,
|
||||||
translateX: 0,
|
translateX: 0,
|
||||||
translateY: 0
|
translateY: 0,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isDragging: false
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理双指缩放(只响应真正的触摸事件)
|
// 触摸事件处理(缩放+拖拽)
|
||||||
let initialDistance = 0
|
let initialDistance = 0
|
||||||
let initialScale = 1
|
let initialScale = 1
|
||||||
|
|
||||||
@@ -284,41 +499,47 @@ const getDistance = (touch1: Touch, touch2: Touch) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchStart = (index: number, event: TouchEvent) => {
|
const handleTouchStart = (index: number, event: TouchEvent) => {
|
||||||
|
const state = imageStates.value[index]
|
||||||
if (event.touches.length === 2) {
|
if (event.touches.length === 2) {
|
||||||
// 双指触摸开始
|
// 双指缩放
|
||||||
initialDistance = getDistance(event.touches[0], event.touches[1])
|
initialDistance = getDistance(event.touches[0], event.touches[1])
|
||||||
initialScale = imageStates.value[index].scale
|
initialScale = state.scale
|
||||||
|
state.isDragging = false
|
||||||
} else if (event.touches.length === 1) {
|
} else if (event.touches.length === 1) {
|
||||||
// 单指触摸,可能是滚动,不阻止默认行为
|
// 单指拖拽准备
|
||||||
return
|
state.startX = event.touches[0].clientX - state.translateX
|
||||||
|
state.startY = event.touches[0].clientY - state.translateY
|
||||||
|
state.isDragging = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchMove = (index: number, event: TouchEvent) => {
|
const handleTouchMove = (index: number, event: TouchEvent) => {
|
||||||
|
const state = imageStates.value[index]
|
||||||
if (event.touches.length === 2) {
|
if (event.touches.length === 2) {
|
||||||
// 双指缩放
|
// 双指缩放处理
|
||||||
event.preventDefault() // 只在双指操作时阻止默认行为
|
event.preventDefault()
|
||||||
const currentDistance = getDistance(event.touches[0], event.touches[1])
|
const currentDistance = getDistance(event.touches[0], event.touches[1])
|
||||||
const scale = initialScale * (currentDistance / initialDistance)
|
const scale = initialScale * (currentDistance / initialDistance)
|
||||||
|
state.scale = Math.min(Math.max(scale, 1), 3) // 限制缩放范围1-3倍
|
||||||
// 限制缩放范围
|
state.isDragging = false
|
||||||
const clampedScale = Math.min(Math.max(scale, 1), 3)
|
} else if (event.touches.length === 1 && state.isDragging && state.scale > 1) {
|
||||||
imageStates.value[index].scale = clampedScale
|
// 单指拖拽处理(仅在缩放后允许)
|
||||||
|
event.preventDefault()
|
||||||
|
state.translateX = event.touches[0].clientX - state.startX
|
||||||
|
state.translateY = event.touches[0].clientY - state.startY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理滚轮缩放(以鼠标位置为中心点缩放)
|
const handleTouchEnd = (index: number) => {
|
||||||
|
imageStates.value[index].isDragging = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标事件处理(滚轮缩放+拖拽)
|
||||||
const handleWheel = (index: number, event: WheelEvent) => {
|
const handleWheel = (index: number, event: WheelEvent) => {
|
||||||
// 确保是鼠标滚轮事件而不是触摸板手势
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) return
|
||||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) {
|
|
||||||
// 可能是水平滚动,不处理缩放
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const currentState = imageStates.value[index]
|
const state = imageStates.value[index]
|
||||||
|
|
||||||
// 获取图片元素
|
|
||||||
const imgElement = document.getElementById(`image-${index}`)?.querySelector('.manga-image')
|
const imgElement = document.getElementById(`image-${index}`)?.querySelector('.manga-image')
|
||||||
if (!imgElement) return
|
if (!imgElement) return
|
||||||
|
|
||||||
@@ -327,71 +548,106 @@ const handleWheel = (index: number, event: WheelEvent) => {
|
|||||||
const mouseX = event.clientX - rect.left
|
const mouseX = event.clientX - rect.left
|
||||||
const mouseY = event.clientY - rect.top
|
const mouseY = event.clientY - rect.top
|
||||||
|
|
||||||
// 计算当前鼠标位置相对于图片中心的偏移(减小移动幅度)
|
// 计算当前鼠标位置相对于图片中心的偏移
|
||||||
const centerX = rect.width / 2.7
|
const centerX = rect.width / 2.7
|
||||||
const centerY = rect.height / 2.5
|
const centerY = rect.height / 2.5
|
||||||
const offsetX = (mouseX - centerX) * 0.4 // 减少移动幅度
|
const offsetX = (mouseX - centerX) * 0.4
|
||||||
const offsetY = (mouseY - centerY) * 0.4 // 减少移动幅度
|
const offsetY = (mouseY - centerY) * 0.4
|
||||||
|
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
// 向上滚动放大
|
// 放大
|
||||||
if (currentState.scale < 3) {
|
if (state.scale < 3) {
|
||||||
const newScale = currentState.scale * 1.1
|
const newScale = state.scale * 1.1
|
||||||
const scaleRatio = newScale / currentState.scale
|
const scaleRatio = newScale / state.scale
|
||||||
|
|
||||||
// 以鼠标位置为中心进行缩放(减小位移)
|
state.scale = newScale
|
||||||
const newTranslateX = currentState.translateX - offsetX * (scaleRatio - 1)
|
state.translateX = state.translateX - offsetX * (scaleRatio - 1)
|
||||||
const newTranslateY = currentState.translateY - offsetY * (scaleRatio - 1)
|
state.translateY = state.translateY - offsetY * (scaleRatio - 1)
|
||||||
|
|
||||||
currentState.scale = newScale
|
|
||||||
currentState.translateX = newTranslateX
|
|
||||||
currentState.translateY = newTranslateY
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 向下滚动缩小
|
// 缩小
|
||||||
if (currentState.scale > 1) {
|
if (state.scale > 1) {
|
||||||
const newScale = currentState.scale / 1.1
|
const newScale = state.scale / 1.1
|
||||||
const scaleRatio = newScale / currentState.scale
|
const scaleRatio = newScale / state.scale
|
||||||
|
|
||||||
// 以鼠标位置为中心进行缩放(减小位移)
|
state.scale = newScale
|
||||||
const newTranslateX = currentState.translateX - offsetX * (scaleRatio - 1)
|
state.translateX = state.translateX - offsetX * (scaleRatio - 1)
|
||||||
const newTranslateY = currentState.translateY - offsetY * (scaleRatio - 1)
|
state.translateY = state.translateY - offsetY * (scaleRatio - 1)
|
||||||
|
|
||||||
currentState.scale = newScale
|
// 缩放到原始大小则重置位置
|
||||||
currentState.translateX = newTranslateX
|
if (state.scale <= 1) {
|
||||||
currentState.translateY = newTranslateY
|
state.scale = 1
|
||||||
|
state.translateX = 0
|
||||||
// 如果缩放到原始大小,重置位置
|
state.translateY = 0
|
||||||
if (currentState.scale <= 1) {
|
|
||||||
currentState.scale = 1
|
|
||||||
currentState.translateX = 0
|
|
||||||
currentState.translateY = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到指定图片
|
// 鼠标拖拽处理
|
||||||
|
const handleMouseDown = (index: number, event: MouseEvent) => {
|
||||||
|
const state = imageStates.value[index]
|
||||||
|
if (state.scale > 1 && event.button === 0) { // 仅左键且缩放后允许拖拽
|
||||||
|
state.startX = event.clientX - state.translateX
|
||||||
|
state.startY = event.clientY - state.startY
|
||||||
|
state.isDragging = true
|
||||||
|
|
||||||
|
// 添加临时鼠标移动和释放事件
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (state.isDragging) {
|
||||||
|
state.translateX = e.clientX - state.startX
|
||||||
|
state.translateY = e.clientY - state.startY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
state.isDragging = false
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 翻页与滚动 - 修复跳转位置
|
||||||
const scrollToImage = (index: number) => {
|
const scrollToImage = (index: number) => {
|
||||||
if (index < 0 || index >= mangaImages.value.length) return
|
if (index < 0 || index >= mangaImages.value.length) return
|
||||||
|
|
||||||
const element = document.getElementById(`image-${index}`)
|
// 增加重试机制和强制滚动逻辑
|
||||||
if (element) {
|
const maxRetries = 15
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
const retryDelay = 150
|
||||||
currentImageIndex.value = index
|
let retryCount = 0
|
||||||
// 关闭抽屉菜单
|
|
||||||
showDrawer.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到下一张图片
|
const tryScroll = () => {
|
||||||
|
const element = document.getElementById(`image-${index}`);
|
||||||
|
if (element) {
|
||||||
|
// 强制计算元素位置
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
// 手动调整滚动位置(解决偏移问题)
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const offset = rect.top + window.scrollY - (window.innerHeight / 2)
|
||||||
|
window.scrollTo({ top: offset, behavior: 'smooth' })
|
||||||
|
|
||||||
|
currentImageIndex.value = index
|
||||||
|
showDrawer.value = false
|
||||||
|
} else if (retryCount < maxRetries) {
|
||||||
|
retryCount++
|
||||||
|
setTimeout(tryScroll, retryDelay)
|
||||||
|
} else {
|
||||||
|
console.warn(`超过最大重试次数,无法滚动到图片 ${index}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryScroll()
|
||||||
|
}
|
||||||
|
// 翻页功能
|
||||||
const nextImage = () => {
|
const nextImage = () => {
|
||||||
if (currentImageIndex.value < mangaImages.value.length - 1) {
|
if (currentImageIndex.value < mangaImages.value.length - 1) {
|
||||||
scrollToImage(currentImageIndex.value + 1)
|
scrollToImage(currentImageIndex.value + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到上一张图片
|
|
||||||
const prevImage = () => {
|
const prevImage = () => {
|
||||||
if (currentImageIndex.value > 0) {
|
if (currentImageIndex.value > 0) {
|
||||||
scrollToImage(currentImageIndex.value - 1)
|
scrollToImage(currentImageIndex.value - 1)
|
||||||
@@ -412,17 +668,14 @@ const throttle = (func: Function, limit: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听滚动事件,更新当前图片索引
|
// 滚动事件处理
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const containers = document.querySelectorAll('.image-container')
|
const containers = document.querySelectorAll('.image-container')
|
||||||
let currentIndex = 0
|
let currentIndex = 0
|
||||||
|
|
||||||
// 使用更精确的计算方式确定当前视口中的图片
|
|
||||||
for (let i = 0; i < containers.length; i++) {
|
for (let i = 0; i < containers.length; i++) {
|
||||||
const rect = containers[i].getBoundingClientRect()
|
const rect = containers[i].getBoundingClientRect()
|
||||||
// 当图片的任意部分在视口中时就认为是当前图片
|
|
||||||
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
||||||
// 优先选择图片中心点在视口中央的图片
|
|
||||||
if (Math.abs(rect.top + rect.height/2 - window.innerHeight/2) <
|
if (Math.abs(rect.top + rect.height/2 - window.innerHeight/2) <
|
||||||
Math.abs(containers[currentIndex].getBoundingClientRect().top +
|
Math.abs(containers[currentIndex].getBoundingClientRect().top +
|
||||||
containers[currentIndex].getBoundingClientRect().height/2 - window.innerHeight/2)) {
|
containers[currentIndex].getBoundingClientRect().height/2 - window.innerHeight/2)) {
|
||||||
@@ -431,7 +684,6 @@ const handleScroll = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当索引真正改变时才更新
|
|
||||||
if (currentImageIndex.value !== currentIndex) {
|
if (currentImageIndex.value !== currentIndex) {
|
||||||
currentImageIndex.value = currentIndex
|
currentImageIndex.value = currentIndex
|
||||||
}
|
}
|
||||||
@@ -441,7 +693,7 @@ const back = () => {
|
|||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用节流优化滚动处理
|
// 节流处理滚动事件
|
||||||
const throttledHandleScroll = throttle(handleScroll, 100)
|
const throttledHandleScroll = throttle(handleScroll, 100)
|
||||||
|
|
||||||
// 键盘事件处理
|
// 键盘事件处理
|
||||||
@@ -470,21 +722,21 @@ watch(
|
|||||||
(newId) => {
|
(newId) => {
|
||||||
console.log('路由参数变化:', newId)
|
console.log('路由参数变化:', newId)
|
||||||
if (newId) {
|
if (newId) {
|
||||||
abortLoading.value = true // 取消之前的加载
|
abortLoading.value = true
|
||||||
fetchAlbum(newId as string)
|
fetchAlbum(newId as string)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
let refreshInterval: number | null = null;
|
|
||||||
|
|
||||||
// 新增:刷新token的函数
|
// Token刷新
|
||||||
|
let refreshInterval: number | null = null;
|
||||||
const refreshToken = async () => {
|
const refreshToken = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const response = await axios.post('/api/user/ref', {
|
await axios.post('/api/user/ref', {
|
||||||
data: token,
|
data: token,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}, {
|
}, {
|
||||||
@@ -497,39 +749,39 @@ const refreshToken = async () => {
|
|||||||
console.error('Refresh token failed:', error);
|
console.error('Refresh token failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 组件挂载时添加事件监听器
|
|
||||||
|
// 组件挂载
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshInterval = window.setInterval(refreshToken, 30000);
|
refreshInterval = window.setInterval(refreshToken, 30000);
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
// 直接监听 manga-reader 元素的滚动事件,而不是 manga-content
|
|
||||||
const mangaReader = document.querySelector('.manga-reader')
|
const mangaReader = document.querySelector('.manga-reader')
|
||||||
if (mangaReader) {
|
if (mangaReader) {
|
||||||
mangaReader.addEventListener('scroll', throttledHandleScroll)
|
mangaReader.addEventListener('scroll', throttledHandleScroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化当前图片索引
|
|
||||||
handleScroll()
|
handleScroll()
|
||||||
})
|
})
|
||||||
// 替换现有的 watch 监听器
|
|
||||||
|
// 监听阅读进度变化并上报
|
||||||
watch([currentImageIndex, loading, mangaImages], ([newIndex, isLoading, images]) => {
|
watch([currentImageIndex, loading, mangaImages], ([newIndex, isLoading, images]) => {
|
||||||
// 只有在非加载状态且索引有效时才发送请求
|
if (albumInfo.value && !isLoading && newIndex >= 0 && newIndex < images.length) {
|
||||||
if (albumInfo.value && !isLoading && newIndex >= 0 &&
|
|
||||||
newIndex < images.length) {
|
|
||||||
reportReadManga(albumInfo.value.album_id, newIndex);
|
reportReadManga(albumInfo.value.album_id, newIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 组件卸载
|
||||||
|
|
||||||
// 组件卸载时移除事件监听器
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
const mangaReader = document.querySelector('.manga-content')
|
|
||||||
|
const mangaReader = document.querySelector('.manga-reader')
|
||||||
if (mangaReader) {
|
if (mangaReader) {
|
||||||
mangaReader.removeEventListener('scroll', throttledHandleScroll)
|
mangaReader.removeEventListener('scroll', throttledHandleScroll)
|
||||||
}
|
}
|
||||||
abortLoading.value = true // 取消加载
|
abortLoading.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -538,11 +790,6 @@ onUnmounted(() => {
|
|||||||
@click="toggleMenu"
|
@click="toggleMenu"
|
||||||
:class="{ 'no-header': shouldHideHeader }">
|
:class="{ 'no-header': shouldHideHeader }">
|
||||||
|
|
||||||
<!-- <!– 始终可见的页码显示 –>-->
|
|
||||||
<!-- <div class="page-indicator">-->
|
|
||||||
<!-- {{ currentPageInfo }}-->
|
|
||||||
<!-- </div>-->
|
|
||||||
|
|
||||||
<!-- 顶部菜单栏 -->
|
<!-- 顶部菜单栏 -->
|
||||||
<div
|
<div
|
||||||
class="menu-bar"
|
class="menu-bar"
|
||||||
@@ -583,13 +830,19 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<div class="drawer-content">
|
<div class="drawer-content">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<button @click="back" class="back-btn">ReiJM</button>
|
<div class="back-btn-wrapper">
|
||||||
|
<button @click="back" class="back-btn" title="返回主页">ReiJM</button>
|
||||||
|
<div class="back-tooltip">返回主页</div>
|
||||||
|
</div>
|
||||||
<button @click="toggleDrawer" class="close-btn">×</button>
|
<button @click="toggleDrawer" class="close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-body">
|
<div class="drawer-body">
|
||||||
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
|
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
|
||||||
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
|
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="toggleLove" class="drawer-item" :class="{ 'loved': isLoved }">
|
||||||
|
{{ isLoved ? '❤️ 取消收藏' : '🤍 收藏漫画' }}
|
||||||
|
</button>
|
||||||
<div class="page-list">
|
<div class="page-list">
|
||||||
<h4>页面列表</h4>
|
<h4>页面列表</h4>
|
||||||
<div
|
<div
|
||||||
@@ -617,6 +870,10 @@ onUnmounted(() => {
|
|||||||
<span class="label">作者:</span>
|
<span class="label">作者:</span>
|
||||||
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
|
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="albumInfo.album_id" class="meta-item">
|
||||||
|
<span class="label">ID:</span>
|
||||||
|
<span class="value">{{ albumInfo.album_id }}</span>
|
||||||
|
</div>
|
||||||
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
|
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
|
||||||
<span class="label">标签:</span>
|
<span class="label">标签:</span>
|
||||||
<span class="value tags">
|
<span class="value tags">
|
||||||
@@ -636,19 +893,27 @@ onUnmounted(() => {
|
|||||||
class="image-container"
|
class="image-container"
|
||||||
:id="`image-${index}`"
|
:id="`image-${index}`"
|
||||||
>
|
>
|
||||||
<img
|
<template v-if="imageLoadStates[index] === 'loaded' && image">
|
||||||
v-if="image"
|
<img
|
||||||
:src="image"
|
:src="image"
|
||||||
:alt="`漫画第 ${index + 1} 页`"
|
:alt="`漫画第 ${index + 1} 页`"
|
||||||
class="manga-image"
|
class="manga-image"
|
||||||
:style="{
|
:style="{
|
||||||
transform: `scale(${imageStates[index].scale}) translate(${imageStates[index].translateX}px, ${imageStates[index].translateY}px)`
|
transform: `scale(${imageStates[index].scale}) translate(${imageStates[index].translateX}px, ${imageStates[index].translateY}px)`
|
||||||
}"
|
}"
|
||||||
@wheel="handleWheel(index, $event)"
|
@wheel="handleWheel(index, $event)"
|
||||||
@touchstart="handleTouchStart(index, $event)"
|
@touchstart="handleTouchStart(index, $event)"
|
||||||
@touchmove="handleTouchMove(index, $event)"
|
@touchmove="handleTouchMove(index, $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="image-placeholder">图片加载中...</div>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 未加载的占位图片 -->
|
||||||
|
<img
|
||||||
|
:src="generatePlaceholderImage()"
|
||||||
|
:alt="`未加载 - 漫画第 ${index + 1} 页`"
|
||||||
|
class="manga-image placeholder"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -672,7 +937,11 @@ onUnmounted(() => {
|
|||||||
padding-top: 0; /* 移除顶部内边距 */
|
padding-top: 0; /* 移除顶部内边距 */
|
||||||
padding-bottom: 60px; /* 添加底部内边距 */
|
padding-bottom: 60px; /* 添加底部内边距 */
|
||||||
}
|
}
|
||||||
|
/* 占位图样式 */
|
||||||
|
.manga-image.placeholder {
|
||||||
|
opacity: 0.7;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
.manga-reader.no-header {
|
.manga-reader.no-header {
|
||||||
padding-bottom: 0; /* 无头部时也无底部内边距 */
|
padding-bottom: 0; /* 无头部时也无底部内边距 */
|
||||||
}
|
}
|
||||||
@@ -795,11 +1064,74 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.drawer-content {
|
||||||
|
padding: 20px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.back-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.2s, visibility 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 201;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn-wrapper:hover .back-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
.drawer-header h3 {
|
.drawer-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -993,4 +1325,21 @@ onUnmounted(() => {
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 添加收藏按钮样式 */
|
||||||
|
.menu-content button.loved {
|
||||||
|
background-color: rgba(255, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content button.loved:hover:not(:disabled) {
|
||||||
|
background-color: rgba(255, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item.loved {
|
||||||
|
background-color: rgba(255, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item.loved:hover {
|
||||||
|
background-color: rgba(255, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,73 +1,143 @@
|
|||||||
<!-- User.vue -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watchEffect } from 'vue'
|
||||||
import GithubLoginButton from './GithubLoginButton.vue'
|
import GithubLoginButton from './GithubLoginButton.vue'
|
||||||
import {useRoute} from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import router from "@/router";
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
githubId: string
|
githubId: string
|
||||||
|
mangas_like?: Record<string, string>
|
||||||
|
album2page?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FavoriteItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
id: string
|
||||||
|
index: number
|
||||||
|
name?: string
|
||||||
|
tags?: string[]
|
||||||
|
totalPages?: number
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isLoggedIn = ref(false)
|
const isLoggedIn = ref(false)
|
||||||
const route = useRoute()
|
const router = useRouter()
|
||||||
|
const isDarkMode = ref(false)
|
||||||
|
|
||||||
// 从URL参数中获取token
|
// 收藏和历史数据
|
||||||
|
const favorites = ref<FavoriteItem[]>([])
|
||||||
|
const historyItems = ref<HistoryItem[]>([])
|
||||||
|
|
||||||
|
// 从URL获取token
|
||||||
const getUrlParameter = (name: string): string | null => {
|
const getUrlParameter = (name: string): string | null => {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
//移除值
|
router.replace({ query: { ...route.query, token: undefined } })
|
||||||
router.replace({
|
return urlParams.get(name)
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
token: undefined,
|
|
||||||
}
|
|
||||||
}); return urlParams.get(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
// 跳转漫画详情
|
||||||
let token = getUrlParameter('token')
|
const goToManga = (mangaId: string) => {
|
||||||
// 如果URL中没有token,则从localStorage中获取
|
router.push(`/manga/${mangaId}`)
|
||||||
if (!token || token === 'error') {
|
}
|
||||||
token = localStorage.getItem('token')
|
|
||||||
}
|
// 获取漫画详情
|
||||||
if (!token || token === 'error') {
|
const fetchMangaDetails = async (item: HistoryItem) => {
|
||||||
if (token === 'error') {
|
try {
|
||||||
error.value = '登录失败'
|
item.loading = true
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch(`/api/manga/mangaE?mangaId=${item.id}`, {
|
||||||
|
headers: { Token: token || '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const manga = await response.json()
|
||||||
|
item.name = manga.name
|
||||||
|
item.tags = manga.tags
|
||||||
|
item.totalPages = manga.image_urls?.length || 0
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取漫画详情出错:', err)
|
||||||
|
} finally {
|
||||||
|
item.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(async () => {
|
||||||
|
// 检查深色模式偏好
|
||||||
|
isDarkMode.value = localStorage.getItem('darkMode') === 'true' ||
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
||||||
|
let token = getUrlParameter('token') || localStorage.getItem('token')
|
||||||
|
|
||||||
|
if (!token || token === 'error') {
|
||||||
|
error.value = token === 'error' ? '登录失败' : null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
isLoggedIn.value = false
|
isLoggedIn.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('token', token)
|
localStorage.setItem('token', token)
|
||||||
//写到localStorage
|
|
||||||
try {
|
try {
|
||||||
// 调用API获取用户信息(需要后端提供此接口)
|
const response = await fetch(`/api/user/data`, { headers: { Token: token }})
|
||||||
const response = await fetch(`/api/user/data`, { headers: { Token: `${token}` }})
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
userInfo.value = await response.json()
|
const userData = await response.json()
|
||||||
|
|
||||||
|
if (userData.id?.includes('Error')) {
|
||||||
|
throw new Error('用户认证失效,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo.value = userData
|
||||||
isLoggedIn.value = true
|
isLoggedIn.value = true
|
||||||
|
|
||||||
|
// 处理收藏
|
||||||
|
if (userData.mangas_like && typeof userData.mangas_like === 'object') {
|
||||||
|
favorites.value = Object.entries(userData.mangas_like)
|
||||||
|
.map(([id, title]) => ({ id, title: title as string }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理历史记录
|
||||||
|
if (userData.album2page && typeof userData.album2page === 'object') {
|
||||||
|
historyItems.value = Object.entries(userData.album2page)
|
||||||
|
.map(([id, index]) => ({ id, index, loading: false }))
|
||||||
|
|
||||||
|
historyItems.value.forEach(item => fetchMangaDetails(item))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('获取用户信息失败')
|
throw new Error('获取用户信息失败')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : '未知错误'
|
error.value = err instanceof Error ? err.message : '未知错误'
|
||||||
isLoggedIn.value = false
|
isLoggedIn.value = false
|
||||||
|
localStorage.removeItem('token')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听深色模式变化
|
||||||
|
watchEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', isDarkMode.value)
|
||||||
|
localStorage.setItem('darkMode', isDarkMode.value.toString())
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-page">
|
<div class="user-page">
|
||||||
<br><br>
|
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" class="loading">
|
||||||
正在加载用户信息...
|
正在加载用户信息...
|
||||||
</div>
|
</div>
|
||||||
@@ -84,23 +154,69 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div v-else-if="userInfo" class="user-container">
|
<div v-else-if="userInfo" class="user-container">
|
||||||
<div class="user-content">
|
<div class="user-content">
|
||||||
<!-- 左侧:用户基本信息 (3/10宽度) -->
|
<!-- 左侧:用户信息 -->
|
||||||
<div class="user-info">
|
<div class="user-info glass-effect">
|
||||||
<img
|
<img
|
||||||
:src="userInfo.avatar_url"
|
:src="userInfo.avatar_url"
|
||||||
:alt="userInfo.username"
|
:alt="userInfo.username"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
/>
|
/>
|
||||||
<h2>{{ userInfo.username }}</h2>
|
<h2>{{ userInfo.username }}</h2>
|
||||||
<p v-if="userInfo.email" class="email">邮箱: {{ userInfo.email }}</p>
|
<p v-if="userInfo.email" class="email">邮箱: {{ userInfo.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions-container">
|
||||||
|
<!-- 右侧:收藏和历史记录 -->
|
||||||
|
<div class="user-actions">
|
||||||
|
<!-- 收藏区域 -->
|
||||||
|
<div class="favorites-section glass-effect">
|
||||||
|
<h3>我的收藏</h3>
|
||||||
|
<div class="scroll-container">
|
||||||
|
<div v-if="favorites.length === 0" class="no-content">
|
||||||
|
暂无收藏内容
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:预留空间 (7/10宽度) -->
|
<ul class="favorites-list" v-else>
|
||||||
<div class="user-actions">
|
<li
|
||||||
<div class="placeholder">
|
v-for="item in favorites"
|
||||||
<!-- 这里可以添加用户操作功能,如编辑资料、登出等 -->
|
:key="item.id"
|
||||||
<h3>用户操作区</h3>
|
class="favorite-item"
|
||||||
<p>此处可以添加用户相关的功能操作</p>
|
@click="goToManga(item.id)"
|
||||||
|
>
|
||||||
|
<span>{{ item.id }}. {{ item.title }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 历史阅读区域 -->
|
||||||
|
<div class="history-section glass-effect">
|
||||||
|
<h3>历史阅读</h3>
|
||||||
|
<div class="scroll-container">
|
||||||
|
<div v-if="historyItems.length === 0" class="no-content">
|
||||||
|
暂无阅读记录
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="history-list" v-else>
|
||||||
|
<li
|
||||||
|
v-for="item in historyItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="history-item"
|
||||||
|
@click="goToManga(item.id)"
|
||||||
|
>
|
||||||
|
<div v-if="item.loading" class="loading-indicator">加载中...</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="item-title">{{ item.name || `漫画 ${item.id}` }}</div>
|
||||||
|
<div class="item-tags">
|
||||||
|
<span v-for="(tag, idx) in item.tags" :key="idx" class="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reading-progress">
|
||||||
|
阅读进度: {{ item.index }} / {{ item.totalPages || '未知' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,122 +225,219 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 基础样式保持不变 */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 新增:为固定导航栏预留顶部空间 */
|
||||||
|
padding-top: 60px; /* 导航栏高度约56px,预留60px避免内容被遮挡 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-page {
|
.user-page {
|
||||||
max-width: 1200px;
|
position: relative;
|
||||||
margin: 0 auto;
|
width: 100%;
|
||||||
padding: 20px;
|
/* 关键修改:计算高度时减去导航栏和页面内边距 */
|
||||||
|
min-height: calc(100vh - 60px); /* 减去顶部预留的60px */
|
||||||
|
padding: 15px; /* 减少内边距,压缩整体高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .error {
|
/* 液态玻璃效果保持不变 */
|
||||||
padding: 20px;
|
.glass-effect {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-effect {
|
||||||
|
background: rgba(30, 41, 59, 0.75);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题切换按钮保持不变 */
|
||||||
|
.theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
text-align: center;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.theme-toggle:hover {
|
||||||
color: #e74c3c;
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .theme-toggle {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载、错误和登录区域调整 */
|
||||||
|
.loading, .error, .login-section {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px auto; /* 减少上下边距 */
|
||||||
|
padding: 15px; /* 减少内边距 */
|
||||||
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-section {
|
.login-section {
|
||||||
background: #f8f9fa;
|
border-radius: 12px;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 40px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-section h2 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-section p {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 用户容器布局 - 关键高度调整 */
|
||||||
.user-container {
|
.user-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
/* 调整高度计算,减去导航栏和内边距 */
|
||||||
|
height: calc(100vh - 100px); /* 60px导航栏 + 40px内边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-content {
|
.user-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 30px;
|
gap: 15px; /* 减少间距 */
|
||||||
background: #f8f9fa;
|
height: 100%;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧用户信息区域 */
|
/* 左侧用户信息区域调整 */
|
||||||
.user-info {
|
.user-info {
|
||||||
flex: 3;
|
flex: 3;
|
||||||
}
|
padding: 20px 15px; /* 减少内边距 */
|
||||||
|
|
||||||
/* 右侧预留区域 */
|
|
||||||
.user-actions {
|
|
||||||
flex: 7;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder h3 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头像样式 */
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 120px;
|
width: 120px; /* 缩小头像尺寸 */
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 15px; /* 减少间距 */
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info h2 {
|
.email {
|
||||||
margin: 10px 0;
|
margin-top: 8px;
|
||||||
color: #333;
|
opacity: 0.8;
|
||||||
|
font-size: 0.95em; /* 略小字体 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info .email,
|
/* 右侧内容区域调整 */
|
||||||
.user-info .github-id {
|
.user-actions {
|
||||||
margin: 10px 0;
|
flex: 7;
|
||||||
color: #666;
|
display: flex;
|
||||||
font-size: 16px;
|
flex-direction: column;
|
||||||
|
gap: 15px; /* 减少区域间距 */
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端响应式布局 */
|
/* 收藏和历史区域 */
|
||||||
@media (max-width: 768px) {
|
.favorites-section, .history-section {
|
||||||
.user-content {
|
flex: 1;
|
||||||
flex-direction: column;
|
padding: 15px; /* 减少内边距 */
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info,
|
/* 滚动容器 */
|
||||||
.user-actions {
|
.scroll-container {
|
||||||
flex: none;
|
flex: 1;
|
||||||
width: 100%;
|
overflow-y: auto;
|
||||||
}
|
padding-right: 8px;
|
||||||
|
margin-top: 10px; /* 减少间距 */
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.user-actions {
|
/* 滚动条美化保持不变 */
|
||||||
margin-top: 20px;
|
.scroll-container::-webkit-scrollbar {
|
||||||
padding-top: 20px;
|
width: 6px;
|
||||||
border-top: 1px solid #e0e0e0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.user-page {
|
.scroll-container::-webkit-scrollbar-track {
|
||||||
padding: 10px;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
width: 100px;
|
background: rgba(255, 255, 255, 0.3);
|
||||||
height: 100px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表样式调整 */
|
||||||
|
.favorite-item, .history-item {
|
||||||
|
padding: 10px 12px; /* 减少内边距 */
|
||||||
|
margin-bottom: 8px; /* 减少间距 */
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .favorite-item, .dark .history-item {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 历史项目样式微调 */
|
||||||
|
.item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px; /* 减少间距 */
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px; /* 减少间距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 0.75em; /* 缩小标签字体 */
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress {
|
||||||
|
font-size: 0.85em; /* 缩小进度文字 */
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态和加载状态 */
|
||||||
|
.no-content, .loading-indicator {
|
||||||
|
padding: 15px; /* 减少内边距 */
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export default defineConfig({
|
|||||||
cssMinify: true,
|
cssMinify: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
cesium(),
|
|
||||||
vue(),
|
vue(),
|
||||||
viteCompression({
|
viteCompression({
|
||||||
verbose: true,
|
verbose: true,
|
||||||
@@ -101,6 +100,33 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["w.godserver.cn",'godserver.cn','www.godserver.cn','rbq.college','mai.godserver.cn'],
|
allowedHosts: ["w.godserver.cn",'godserver.cn','www.godserver.cn','rbq.college','mai.godserver.cn'],
|
||||||
proxy: {
|
proxy: {
|
||||||
|
"/aapi": {
|
||||||
|
target: "https://reijm.godserver.cn/api/",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Pragma": "no-cache"
|
||||||
|
// 移除 Accept 头部,避免覆盖客户端发送的 Accept: text/event-stream
|
||||||
|
},
|
||||||
|
configure: (proxy, options) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||||
|
// 确保不覆盖客户端的 Accept 头部
|
||||||
|
if (req.headers.accept) {
|
||||||
|
proxyReq.setHeader('Accept', req.headers.accept);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||||
|
// 确保流式响应的头部设置正确
|
||||||
|
proxyRes.headers['Content-Type'] = 'text/event-stream';
|
||||||
|
proxyRes.headers['Cache-Control'] = 'no-cache';
|
||||||
|
proxyRes.headers['Connection'] = 'keep-alive';
|
||||||
|
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://127.0.0.1:8981/api",
|
target: "http://127.0.0.1:8981/api",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -63,6 +63,20 @@
|
|||||||
<artifactId>jsch</artifactId>
|
<artifactId>jsch</artifactId>
|
||||||
<version>0.1.55</version>
|
<version>0.1.55</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- 工具类(字符串处理等) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
<version>3.12.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<profiles>
|
<profiles>
|
||||||
<profile>
|
<profile>
|
||||||
|
|||||||
@@ -40,6 +40,33 @@ public class ApiServerV1 {
|
|||||||
.create();
|
.create();
|
||||||
private static final Map<String, Status> statusMap = new ConcurrentHashMap<>();
|
private static final Map<String, Status> statusMap = new ConcurrentHashMap<>();
|
||||||
private static volatile List<Server> tempServerList = new CopyOnWriteArrayList<>();
|
private static volatile List<Server> tempServerList = new CopyOnWriteArrayList<>();
|
||||||
|
@Scheduled(cron = "0 0 0 * * ?") // 每天凌晨执行
|
||||||
|
public void dailyDatabaseCleanup() {
|
||||||
|
log.info("开始执行每日数据库整理任务");
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Status> historicalServers = statusDao.findAll();
|
||||||
|
|
||||||
|
//如果90天意外的数据删除
|
||||||
|
List<Status> needDelete = historicalServers.stream()
|
||||||
|
.filter(server -> server.getTimestamp().isBefore(LocalDateTime.now().minusDays(90)))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
statusDao.deleteAll(needDelete);
|
||||||
|
log.info("已删除{}条数据", needDelete.size());
|
||||||
|
|
||||||
|
//删除所有的process数据
|
||||||
|
List<Status> update = historicalServers.stream()
|
||||||
|
.map(server -> {
|
||||||
|
server.setProcesses(null);
|
||||||
|
return server;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
statusDao.saveAll(update);
|
||||||
|
log.info("已更新{}条数据", update.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("每日数据库整理任务执行失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@Scheduled(fixedRate = 60000)
|
@Scheduled(fixedRate = 60000)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package org.ast.reisaadminspring.been;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Document(collection = "chat_messages")
|
||||||
|
public class ChatMessage {
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
private String sessionId;
|
||||||
|
private String role; // "user" 或 "assistant"
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionId() {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionId(String sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(LocalDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.ast.reisaadminspring.been;
|
|||||||
import org.springframework.data.mongodb.core.mapping.Document;
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Document
|
@Document
|
||||||
@@ -25,6 +26,13 @@ public class Status {
|
|||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
public Status() {}
|
public Status() {}
|
||||||
|
// 在 Status 类中添加
|
||||||
|
public String getFormattedTimestamp() {
|
||||||
|
if (timestamp != null) {
|
||||||
|
return timestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getTime() {
|
public Long getTime() {
|
||||||
return time;
|
return time;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.ast.reisaadminspring.been;
|
||||||
|
|
||||||
|
public class Tool {
|
||||||
|
private String name;
|
||||||
|
private String args;
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getArgs() {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArgs(String args) {
|
||||||
|
this.args = args;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.ast.reisaadminspring.been;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ToolDecision {
|
||||||
|
private boolean shouldUseTool;
|
||||||
|
private List<Tool> toolArgs;
|
||||||
|
public ToolDecision(boolean b) {
|
||||||
|
this.shouldUseTool = b;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isShouldUseTool() {
|
||||||
|
return shouldUseTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShouldUseTool(boolean shouldUseTool) {
|
||||||
|
this.shouldUseTool = shouldUseTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Tool> getToolArgs() {
|
||||||
|
return toolArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setToolArgs(List<Tool> toolArgs) {
|
||||||
|
this.toolArgs = toolArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
// add_documents 接口请求参数(包含 documents 数组)
|
||||||
|
public class AddDocumentRequest {
|
||||||
|
// 待添加的文档列表
|
||||||
|
private List<Document> documents;
|
||||||
|
|
||||||
|
// 无参构造
|
||||||
|
public AddDocumentRequest() {}
|
||||||
|
|
||||||
|
// 有参构造
|
||||||
|
public AddDocumentRequest(List<Document> documents) {
|
||||||
|
this.documents = documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getter/setter
|
||||||
|
public List<Document> getDocuments() {
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocuments(List<Document> documents) {
|
||||||
|
this.documents = documents;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
// add_documents 接口响应结果
|
||||||
|
public class AddDocumentResponse {
|
||||||
|
// 成功添加的文档数量(如 1)
|
||||||
|
private Integer added_count;
|
||||||
|
// 响应消息(如“Successfully added 1 document chunks...”)
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
// 无参构造
|
||||||
|
public AddDocumentResponse() {}
|
||||||
|
|
||||||
|
// getter/setter
|
||||||
|
public Integer getAdded_count() {
|
||||||
|
return added_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAdded_count(Integer added_count) {
|
||||||
|
this.added_count = added_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
public class Document {
|
||||||
|
// 文档内容(如“Rening是一个熟悉运维...的可爱萝莉”)
|
||||||
|
private String content;
|
||||||
|
// 文档来源(如“Rening介绍”)
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
// 无参构造(Gson 反序列化需默认构造)
|
||||||
|
public Document() {}
|
||||||
|
|
||||||
|
// 有参构造(方便创建对象)
|
||||||
|
public Document(String content, String source) {
|
||||||
|
this.content = content;
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动编写 getter/setter
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSource(String source) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
// query 接口请求参数(仅需传入 question)
|
||||||
|
public class QueryRequest {
|
||||||
|
// 待查询的问题(如“Reisasol是谁?”)
|
||||||
|
private String question;
|
||||||
|
|
||||||
|
// 无参构造
|
||||||
|
public QueryRequest() {}
|
||||||
|
|
||||||
|
// 有参构造
|
||||||
|
public QueryRequest(String question) {
|
||||||
|
this.question = question;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getter/setter
|
||||||
|
public String getQuestion() {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQuestion(String question) {
|
||||||
|
this.question = question;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
// query 接口响应结果
|
||||||
|
public class QueryResponse {
|
||||||
|
// 最终回答(如“Reisasol(零咲)是一只由Reisa研发的猫娘。”)
|
||||||
|
private String answer;
|
||||||
|
// 回答的上下文(多个检索文档内容拼接)
|
||||||
|
private String context;
|
||||||
|
// 检索到的相关文档列表(按相关性排序)
|
||||||
|
private List<RetrievedDoc> retrieved_docs;
|
||||||
|
|
||||||
|
// 无参构造
|
||||||
|
public QueryResponse() {}
|
||||||
|
|
||||||
|
// getter/setter
|
||||||
|
public String getAnswer() {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAnswer(String answer) {
|
||||||
|
this.answer = answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContext() {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContext(String context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RetrievedDoc> getRetrieved_docs() {
|
||||||
|
return retrieved_docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRetrieved_docs(List<RetrievedDoc> retrieved_docs) {
|
||||||
|
this.retrieved_docs = retrieved_docs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.ast.reisaadminspring.been.milvus;
|
||||||
|
|
||||||
|
// 响应中“检索到的文档”结构
|
||||||
|
public class RetrievedDoc {
|
||||||
|
// 相似度距离(数值越小,与问题相关性越高)
|
||||||
|
private Double distance;
|
||||||
|
// 文档来源(如“ReiSaol角色定义”)
|
||||||
|
private String source;
|
||||||
|
// 文档文本内容(生成回答的上下文)
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
// 无参构造
|
||||||
|
public RetrievedDoc() {}
|
||||||
|
|
||||||
|
// getter/setter
|
||||||
|
public Double getDistance() {
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDistance(Double distance) {
|
||||||
|
this.distance = distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSource(String source) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getText() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setText(String text) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.ast.reisaadminspring.been.ollama;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama消息请求类
|
||||||
|
* 用于构建发送给Ollama API的请求消息
|
||||||
|
*/
|
||||||
|
public class OllamaRequest {
|
||||||
|
private String model;
|
||||||
|
private List<Map<String, Object>> messages;
|
||||||
|
private boolean stream;
|
||||||
|
private String keep_alive;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public OllamaRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public OllamaRequest(String model, List<Map<String, Object>> messages) {
|
||||||
|
this.model = model;
|
||||||
|
this.messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModel(String model) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getMessages() {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessages(List<Map<String, Object>> messages) {
|
||||||
|
this.messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStream() {
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStream(boolean stream) {
|
||||||
|
this.stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getKeep_alive() {
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKeep_alive(String keep_alive) {
|
||||||
|
this.keep_alive = keep_alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package org.ast.reisaadminspring.been.ollama;
|
||||||
|
|
||||||
|
public class OllamaResponse {
|
||||||
|
private String model;
|
||||||
|
private String createdAt;
|
||||||
|
private Message message;
|
||||||
|
private boolean done;
|
||||||
|
private long total_duration;
|
||||||
|
private long load_duration;
|
||||||
|
private int prompt_eval_count;
|
||||||
|
private long prompt_eval_duration;
|
||||||
|
private int eval_count;
|
||||||
|
private long eval_duration;
|
||||||
|
|
||||||
|
// 内部类用于表示消息对象
|
||||||
|
public static class Message {
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModel(String model) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(String createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Message getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(Message message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDone() {
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDone(boolean done) {
|
||||||
|
this.done = done;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotal_duration() {
|
||||||
|
return total_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotal_duration(long total_duration) {
|
||||||
|
this.total_duration = total_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLoad_duration() {
|
||||||
|
return load_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoad_duration(long load_duration) {
|
||||||
|
this.load_duration = load_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public int getEval_count() {
|
||||||
|
return eval_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEval_count(int eval_count) {
|
||||||
|
this.eval_count = eval_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrompt_eval_count(int prompt_eval_count) {
|
||||||
|
this.prompt_eval_count = prompt_eval_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrompt_eval_duration(long prompt_eval_duration) {
|
||||||
|
this.prompt_eval_duration = prompt_eval_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getEval_duration() {
|
||||||
|
return eval_duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEval_duration(long eval_duration) {
|
||||||
|
this.eval_duration = eval_duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package org.ast.reisaadminspring.bot;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.ast.reisaadminspring.been.Tool;
|
||||||
|
import org.ast.reisaadminspring.been.ollama.OllamaResponse;
|
||||||
|
import org.ast.reisaadminspring.bot.core.C2CMessageSender;
|
||||||
|
import org.ast.reisaadminspring.bot.core.MessageSender;
|
||||||
|
import org.ast.reisaadminspring.bot.core.WebSocketBot;
|
||||||
|
import org.ast.reisaadminspring.service.MailService;
|
||||||
|
import org.ast.reisaadminspring.service.OllamaService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReningBot {
|
||||||
|
private final OllamaService ollamaService;
|
||||||
|
private Gson gson = new Gson();
|
||||||
|
public ReningBot(OllamaService ollamaService) {
|
||||||
|
this.ollamaService = ollamaService;
|
||||||
|
}
|
||||||
|
@Autowired
|
||||||
|
private MailService mailService;
|
||||||
|
public interface MessageHandler {
|
||||||
|
void sendText(String content, int index);
|
||||||
|
void sendImage(String content,String base64,int index) throws IOException;
|
||||||
|
}
|
||||||
|
private ExecutorService messageExecutor = Executors.newFixedThreadPool(10); // 根据业务需求调整线程数
|
||||||
|
private Logger log = org.slf4j.LoggerFactory.getLogger(ReningBot.class);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
// 启动Bot
|
||||||
|
WebSocketBot bot = new WebSocketBot("102815116", "emv4DMVenx7HRblv6HSdozAMYkw8KWiv");
|
||||||
|
bot.setCustomMessageHandler((message, sender) -> {
|
||||||
|
messageExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
String id = message.get("id").getAsString();
|
||||||
|
aiBotDo(message, sender);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String groupId = message.get("group_id").getAsString();
|
||||||
|
String id = message.get("id").getAsString();
|
||||||
|
sender.sendText(groupId, id, "处理消息时发生错误,请稍后再试。", 100);
|
||||||
|
log.error("消息处理异常", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bot.setCustomC2CMessageHandler((message, sender) -> {
|
||||||
|
messageExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
String content = message.get("content").getAsString();
|
||||||
|
String openId = message.get("author").getAsJsonObject().get("id").getAsString();
|
||||||
|
String messageId = message.get("id").getAsString();
|
||||||
|
// 获取消息内容
|
||||||
|
aiBotDoC2C(message, sender, content, openId, messageId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.connect();
|
||||||
|
bot.startMessageProcessing();
|
||||||
|
System.out.println("Rening启动");
|
||||||
|
}
|
||||||
|
// 修改 aiBotDoC2C 方法
|
||||||
|
private void aiBotDoC2C(JsonObject message, C2CMessageSender sender, String content, String openId, String messageId) {
|
||||||
|
MessageHandler messageHandler = new MessageHandler() {
|
||||||
|
int index = 0;
|
||||||
|
@Override
|
||||||
|
public void sendText(String content, int index) {
|
||||||
|
this.index++;
|
||||||
|
sender.sendText(openId, messageId, content, this.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendImage(String content,String base64, int index) throws IOException {
|
||||||
|
this.index++;
|
||||||
|
sender.sendImage(openId, messageId, content,base64,this.index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
processMessage(content,openId, messageHandler);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sender.sendText(messageId, openId, "处理消息时发生错误,请稍后再试。", 100);
|
||||||
|
log.error("消息处理异常", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 修改 aiBotDo 方法
|
||||||
|
private void aiBotDo(JsonObject message, MessageSender sender) throws Exception {
|
||||||
|
|
||||||
|
String content = message.get("content").getAsString();
|
||||||
|
String groupId = message.get("group_id").getAsString();
|
||||||
|
String userId = message.get("author").getAsJsonObject().get("id").getAsString();
|
||||||
|
String messageId = message.get("id").getAsString();
|
||||||
|
|
||||||
|
MessageHandler messageHandler = new MessageHandler() {
|
||||||
|
int index = 0;
|
||||||
|
@Override
|
||||||
|
public void sendText(String content, int index) {
|
||||||
|
this.index++;
|
||||||
|
sender.sendText(groupId, messageId, content, this.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendImage(String content, String base64, int index) throws IOException {
|
||||||
|
this.index++;
|
||||||
|
sender.sendImage(groupId, messageId, content, base64, this.index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processMessage(content,userId, messageHandler);
|
||||||
|
}
|
||||||
|
private void processMessage(String content,String sessionId, MessageHandler messageHandler) {
|
||||||
|
log.info("收到消息:{}", content);
|
||||||
|
//指令处理
|
||||||
|
if (content.contains("/")) {
|
||||||
|
String command = content.split("/")[1];
|
||||||
|
boolean ifRun = false;
|
||||||
|
switch ( command){
|
||||||
|
case "help":
|
||||||
|
messageHandler.sendText("指令列表:\n",1003);
|
||||||
|
ifRun = true;
|
||||||
|
break;
|
||||||
|
case "clear":
|
||||||
|
messageHandler.sendText("已清空上下文。", 1003);
|
||||||
|
ollamaService.clearSession(sessionId);
|
||||||
|
ifRun = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ifRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<Tool,String> toolStringMap = new HashMap<>();
|
||||||
|
OllamaResponse response = ollamaService.getResponse(sessionId, content,toolStringMap,messageHandler);
|
||||||
|
System.out.println(gson.toJson( response));
|
||||||
|
if (response.isDone()) {
|
||||||
|
int x = 0;
|
||||||
|
messageHandler.sendText(response.getMessage().getContent().replaceAll("\n\n","\n").replaceAll("\\(继续调用:..\\)",""), 1);
|
||||||
|
// for (Tool tool : toolStringMap.keySet()) {
|
||||||
|
// x ++;
|
||||||
|
// messageHandler.sendText("使用工具:" + tool.getName() + ",参数:" + gson.toJson(tool.getArgs()) + "\n返回内容:" + toolStringMap.get(tool), 10000+x);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}catch (Exception e) {
|
||||||
|
log.error("处理消息时发生错误", e);
|
||||||
|
messageHandler.sendText("处理消息时发生错误,请稍后再试。", 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// C2CMessageHandler.java
|
||||||
|
package org.ast.reisaadminspring.bot.core;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
public interface C2CMessageHandler {
|
||||||
|
/**
|
||||||
|
* 处理C2C消息
|
||||||
|
* @param message 消息内容
|
||||||
|
* @param sender 消息发送器
|
||||||
|
*/
|
||||||
|
void handleC2CMessage(JsonObject message, C2CMessageSender sender);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// C2CMessageSender.java
|
||||||
|
package org.ast.reisaadminspring.bot.core;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface C2CMessageSender {
|
||||||
|
/**
|
||||||
|
* 发送文本消息
|
||||||
|
* @param openId 用户openid
|
||||||
|
* @param id 消息id
|
||||||
|
* @param text 文本内容
|
||||||
|
* @param msgSeq 消息序号
|
||||||
|
* @return 响应结果
|
||||||
|
*/
|
||||||
|
JsonObject sendText(String openId, String id, String text, int msgSeq);
|
||||||
|
JsonObject withDraw( String openId, String id) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送图片消息
|
||||||
|
* @param openId 用户openid
|
||||||
|
* @param id 消息id
|
||||||
|
* @param text 文本内容
|
||||||
|
* @param imageUrl 图片URL
|
||||||
|
* @param msgSeq 消息序号
|
||||||
|
* @return 响应结果
|
||||||
|
* @throws IOException IO异常
|
||||||
|
*/
|
||||||
|
JsonObject sendImage(String openId, String id, String text, String imageUrl, int msgSeq) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.ast.reisaadminspring.bot.core;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface MessageHandler {
|
||||||
|
void handleMessage(JsonObject message, MessageSender sender) throws InterruptedException, IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.ast.reisaadminspring.bot.core;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface MessageSender {
|
||||||
|
JsonObject sendText(String groupId, String id, String text, int msgSeq);
|
||||||
|
JsonObject sendImage(String groupId, String id, String text, String imageUrl, int msgSeq) throws IOException;
|
||||||
|
JsonObject withDraw( String groupId, String id) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
package org.ast.reisaadminspring.bot.core;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import okhttp3.*;
|
||||||
|
import okio.ByteString;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class WebSocketBot {
|
||||||
|
private static final String accessUrl = "https://bots.qq.com/app/getAppAccessToken";
|
||||||
|
private static final String wsserverUrl = "wss://api.sgroup.qq.com/websocket";
|
||||||
|
// private static final String wsserverUrl = "wss://tencentbot.godserver.cn:8443";
|
||||||
|
private static final String chatUrl = "https://api.sgroup.qq.com";
|
||||||
|
private static String session_id = "";
|
||||||
|
private String appId = "";
|
||||||
|
private String clientSecret = "";
|
||||||
|
private static long seq = 0;
|
||||||
|
// 在 WebSocketBot 类中添加代理相关字段
|
||||||
|
|
||||||
|
private boolean useProxy = false;
|
||||||
|
|
||||||
|
private static String PROXY_HOST = "100.80.156.98";
|
||||||
|
private static int PROXY_PORT = 7892;
|
||||||
|
private static final String PROXY_USERNAME = "34iawgzer8";
|
||||||
|
private static final String PROXY_PASSWORD = "h9ht;;;";
|
||||||
|
// SOCKS5代理实例(OkHttp使用)
|
||||||
|
// ======================== 2. 配置带认证的SOCKS5代理 ========================
|
||||||
|
// 代理认证器(处理Clash的用户名密码验证)
|
||||||
|
private static final Authenticator PROXY_AUTHENTICATOR = new Authenticator() {
|
||||||
|
@Override
|
||||||
|
public Request authenticate(Route route, Response response) throws IOException {
|
||||||
|
// 生成代理认证的Base64编码(格式:用户名:密码)
|
||||||
|
String credential = Credentials.basic(PROXY_USERNAME, PROXY_PASSWORD);
|
||||||
|
// 给请求添加代理认证头
|
||||||
|
return response.request().newBuilder()
|
||||||
|
.header("Proxy-Authorization", credential)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static final Proxy SOCKS5_PROXY = new Proxy(
|
||||||
|
Proxy.Type.SOCKS,
|
||||||
|
new InetSocketAddress(PROXY_HOST, PROXY_PORT)
|
||||||
|
);
|
||||||
|
|
||||||
|
private OkHttpClient client = getClient();
|
||||||
|
|
||||||
|
private OkHttpClient getClient() {
|
||||||
|
if (!useProxy) {
|
||||||
|
return new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.proxy(SOCKS5_PROXY) // 添加SOCKS5代理
|
||||||
|
.proxyAuthenticator(PROXY_AUTHENTICATOR) // 添加代理认证
|
||||||
|
// .proxy(new Proxy(proxyType, new InetSocketAddress(proxyHost, proxyPort))) // 新增代理
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private OkHttpClient webClient = client;
|
||||||
|
private OkHttpClient messageClient = webClient;
|
||||||
|
private WebSocket webSocket;
|
||||||
|
private String authToken;
|
||||||
|
|
||||||
|
private boolean isConnected = false;
|
||||||
|
private long heartbeatInterval = 0;
|
||||||
|
private long messageId;
|
||||||
|
|
||||||
|
private final Map<String, JsonObject> messages = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, JsonObject> returnMessages = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private final Map<String, JsonObject> c2cmessages = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, JsonObject> c2creturnMessages = new ConcurrentHashMap<>();
|
||||||
|
private final Deque<String> c2cdeque = new ArrayDeque<>();
|
||||||
|
|
||||||
|
private final Deque<String> deque = new ArrayDeque<>();
|
||||||
|
private boolean run = true;
|
||||||
|
private static boolean isRunning = true;
|
||||||
|
private MessageHandler customMessageHandler;
|
||||||
|
private static Gson gson = new Gson();
|
||||||
|
private int waitSeconds = 0;
|
||||||
|
private static String latesetId = "";
|
||||||
|
public static Logger logger = org.slf4j.LoggerFactory.getLogger(WebSocketBot.class);
|
||||||
|
|
||||||
|
|
||||||
|
public WebSocketBot(String appId, String clientSecret) {
|
||||||
|
this.appId = appId;
|
||||||
|
this.clientSecret = clientSecret;
|
||||||
|
getAuthToken();
|
||||||
|
logger.info("[{}]开始连接", this.appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getLatesetId() {
|
||||||
|
return latesetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setLatesetId(String latesetId) {
|
||||||
|
WebSocketBot.latesetId = latesetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void getAuthToken() {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject.addProperty("appId", this.appId);
|
||||||
|
jsonObject.addProperty("clientSecret", this.clientSecret);
|
||||||
|
RequestBody requestBody = RequestBody.create(
|
||||||
|
MediaType.parse("application/json; charset=utf-8"),
|
||||||
|
jsonObject.toString()
|
||||||
|
);
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(accessUrl)
|
||||||
|
.post(requestBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("Unexpected code: " + response.code());
|
||||||
|
}
|
||||||
|
String responseBody = response.body().string();
|
||||||
|
JsonObject jsonObjectRes = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||||
|
authToken = jsonObjectRes.get("access_token").getAsString();
|
||||||
|
waitSeconds = jsonObjectRes.get("expires_in").getAsInt();
|
||||||
|
//启动异步waitSeconds等待
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
logger.info("[{}]开始等待:{}秒后刷新令牌", this.appId, waitSeconds);
|
||||||
|
System.out.println("启动 令牌刷新 等待:" + waitSeconds + "秒后刷新令牌");
|
||||||
|
Thread.sleep(waitSeconds * 1000L);
|
||||||
|
if (!isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getAuthToken();
|
||||||
|
logger.info("[{}]获取令牌成功,authToken:{}", this.appId, authToken);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
System.out.println("authToken:" + authToken);
|
||||||
|
logger.info("[{}]获取令牌成功,authToken:{}", this.appId, authToken);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("[{}]获取令牌失败:{}", this.appId, e.getMessage());
|
||||||
|
System.err.println("获取令牌失败: " + e.getMessage());
|
||||||
|
// 建议添加重试逻辑或更友好的错误处理
|
||||||
|
throw new RuntimeException("无法获取认证令牌,请检查网络或配置", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inConnecting = false;
|
||||||
|
public void setCustomMessageHandler(MessageHandler handler) {
|
||||||
|
this.customMessageHandler = handler;
|
||||||
|
}
|
||||||
|
// 在 WebSocketBot 类中添加字段
|
||||||
|
private C2CMessageHandler customC2CMessageHandler;
|
||||||
|
|
||||||
|
// 添加设置自定义C2C消息处理器的方法
|
||||||
|
public void setCustomC2CMessageHandler(C2CMessageHandler handler) {
|
||||||
|
this.customC2CMessageHandler = handler;
|
||||||
|
}
|
||||||
|
private boolean hasReing = false;
|
||||||
|
public void connect() {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(wsserverUrl)
|
||||||
|
.header("Authorization", "QQBot " + authToken)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.build();
|
||||||
|
final String useAppID = this.appId;
|
||||||
|
WebSocketListener listener = new WebSocketListener() {
|
||||||
|
@Override
|
||||||
|
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
|
||||||
|
WebSocketBot.this.webSocket = webSocket;
|
||||||
|
isConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
|
||||||
|
try {
|
||||||
|
handleMessage(text);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {
|
||||||
|
//System.out.println("收到二进制消息,长度: " + bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||||
|
//System.err.println( "WebSocket is closing... code: " + code + " reason: " + reason);
|
||||||
|
logger.warn("[{}]WebSocket is closing... code: {} reason: {}", useAppID, code, reason);
|
||||||
|
isConnected = false;
|
||||||
|
|
||||||
|
if (code != 4924) { // 非IP黑名单情况
|
||||||
|
if (code != 4903) {
|
||||||
|
reconnect();
|
||||||
|
} else {
|
||||||
|
System.err.println("超出会话限制,请等待或关闭");
|
||||||
|
isRunning = false;
|
||||||
|
}
|
||||||
|
} else { // IP黑名单情况 (code == 4924)
|
||||||
|
logger.error("[{}]Need IP White Page", useAppID);
|
||||||
|
System.err.println("Need IP White Page");
|
||||||
|
if (!hasReing) {
|
||||||
|
reconnectWithIPBlacklistStrategy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, Response response) {
|
||||||
|
logger.error("[{}]WebSocket 连接失败: {}", useAppID, t.getMessage());
|
||||||
|
|
||||||
|
// 检查是否是IP黑名单导致的连接失败
|
||||||
|
if (t.getMessage() != null && t.getMessage().contains("4924")) {
|
||||||
|
logger.error("[{}]WebSocket 连接失败,疑似IP黑名单", useAppID);
|
||||||
|
reconnectWithIPBlacklistStrategy();
|
||||||
|
} else {
|
||||||
|
if (!hasReing) {
|
||||||
|
reconnectWithIPBlacklistStrategy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||||
|
logger.warn("[{}]WebSocket is closed... code: {} reason: {}", useAppID, code, reason);
|
||||||
|
//System.err.println( "WebSocket is closing... code: " + code + " reason: " + reason);
|
||||||
|
//reconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
webClient.newWebSocket(request, listener);
|
||||||
|
}
|
||||||
|
private void reconnectWithIPBlacklistStrategy() {
|
||||||
|
useProxy = true;
|
||||||
|
client = getClient();
|
||||||
|
webClient = client;
|
||||||
|
messageClient = webClient;
|
||||||
|
logger.warn("[{}]使用代理重连", appId);
|
||||||
|
reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void sendLogin() {
|
||||||
|
if (isRunning) {
|
||||||
|
JsonObject loginJsonObject = new JsonObject();
|
||||||
|
long intents = (1L) | (1L << 1) | (1L << 25);
|
||||||
|
|
||||||
|
loginJsonObject.addProperty("op", 2);
|
||||||
|
JsonObject d = new JsonObject();
|
||||||
|
d.addProperty("token", "QQBot " + authToken);
|
||||||
|
d.addProperty("intents", intents);
|
||||||
|
JsonArray shard = new JsonArray();
|
||||||
|
shard.add(0);
|
||||||
|
shard.add(1);
|
||||||
|
d.add("shard", shard);
|
||||||
|
JsonObject properties = new JsonObject();
|
||||||
|
properties.addProperty("$os", "Windows");
|
||||||
|
properties.addProperty("$browser", "Chrome");
|
||||||
|
properties.addProperty("$device", "");
|
||||||
|
loginJsonObject.add("d", d);
|
||||||
|
webSocket.send(loginJsonObject.toString());
|
||||||
|
//System.out.println(loginJsonObject.toString());
|
||||||
|
System.out.println("鉴权中...");
|
||||||
|
logger.info("[{}]正在鉴权...", this.appId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
if (webSocket != null) {
|
||||||
|
webSocket.close(1000, "正常关闭");
|
||||||
|
webSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconnect() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(1000);
|
||||||
|
logger.info("[{}]尝试重新连接...", this.appId);
|
||||||
|
connect();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMessage(String message) throws IOException {
|
||||||
|
JsonObject jsonObject = gson.fromJson(message, JsonObject.class);
|
||||||
|
isConnected = true;
|
||||||
|
int op = jsonObject.get("op").getAsInt();
|
||||||
|
if (jsonObject.has("s")) {
|
||||||
|
seq = jsonObject.get("s").getAsLong();
|
||||||
|
}
|
||||||
|
if (op == 9) {
|
||||||
|
System.err.println("出错!");
|
||||||
|
System.err.println("bot已关闭!");
|
||||||
|
}
|
||||||
|
if (op == 10) {
|
||||||
|
heartbeatInterval = jsonObject.get("d").getAsJsonObject().get("heartbeat_interval").getAsLong();
|
||||||
|
sendLogin();
|
||||||
|
}
|
||||||
|
if (op == 0) {
|
||||||
|
String t = jsonObject.get("t").getAsString();
|
||||||
|
if (t.equals("READY")) {
|
||||||
|
logger.info("[{}]已登录", this.appId);
|
||||||
|
//System.out.println("登录成功!");
|
||||||
|
session_id = jsonObject.get("d").getAsJsonObject().get("session_id").getAsString();
|
||||||
|
loginFinish(jsonObject);
|
||||||
|
} else if (t.equals("GROUP_AT_MESSAGE_CREATE")) {
|
||||||
|
messageAdd(message);
|
||||||
|
} else if (t.equals("C2C_MESSAGE_CREATE")) {
|
||||||
|
c2cmessageAdd(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (op == 2) {
|
||||||
|
loginFinish(jsonObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void c2cmessageAdd(String message) {
|
||||||
|
JsonObject jsonObject = gson.fromJson(message, JsonObject.class);
|
||||||
|
JsonObject d = jsonObject.getAsJsonObject("d");
|
||||||
|
|
||||||
|
String id = d.get("id").getAsString();
|
||||||
|
c2cmessages.put(id, d);
|
||||||
|
c2cdeque.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void messageAdd(String message) throws IOException {
|
||||||
|
JsonObject jsonObject = gson.fromJson(message, JsonObject.class);
|
||||||
|
JsonObject d = jsonObject.getAsJsonObject("d");
|
||||||
|
|
||||||
|
String id = d.get("id").getAsString();
|
||||||
|
messages.put(id, d);
|
||||||
|
deque.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startMessageProcessing() {
|
||||||
|
new Thread(() -> {
|
||||||
|
while (run) {
|
||||||
|
if (!deque.isEmpty()) {
|
||||||
|
String id = deque.poll();
|
||||||
|
try {
|
||||||
|
messageDo(id);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
new Thread(() -> {
|
||||||
|
while (run) {
|
||||||
|
if (!c2cdeque.isEmpty()) {
|
||||||
|
String id = c2cdeque.poll();
|
||||||
|
try {
|
||||||
|
c2cmessageDo(id);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Thread.sleep(50);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
// 添加获取C2C返回消息的方法
|
||||||
|
public Map<String, JsonObject> getC2cReturnMessages() {
|
||||||
|
return c2creturnMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 c2cmessageDo 方法
|
||||||
|
private void c2cmessageDo(String id) throws IOException, InterruptedException {
|
||||||
|
JsonObject jsonObject = c2cmessages.get(id);
|
||||||
|
String openId = jsonObject.get("author").getAsJsonObject().get("id").getAsString();
|
||||||
|
|
||||||
|
if (customC2CMessageHandler != null) {
|
||||||
|
C2CMessageSender sender = new C2CMessageSender() {
|
||||||
|
@Override
|
||||||
|
public JsonObject sendText(String openId, String id, String text, int msgSeq) {
|
||||||
|
c2csendChatText(openId, id, text, msgSeq);
|
||||||
|
return c2creturnMessages.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonObject withDraw(String openId, String id) throws IOException {
|
||||||
|
c2cdeleteMessage(openId, id);
|
||||||
|
return c2creturnMessages.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonObject sendImage(String openId, String id, String text, String imageUrl, int msgSeq) throws IOException {
|
||||||
|
c2csendChat(openId, id, text, imageUrl, msgSeq);
|
||||||
|
return c2creturnMessages.get(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
customC2CMessageHandler.handleC2CMessage(jsonObject, sender);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 记录异常但不中断处理流程
|
||||||
|
System.err.println("处理C2C消息时发生异常: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 默认处理逻辑
|
||||||
|
try {
|
||||||
|
c2csendChatText(openId, id, "私聊示例", 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void messageDo(String id) throws IOException, InterruptedException {
|
||||||
|
JsonObject jsonObject = messages.get(id);
|
||||||
|
String groupId = jsonObject.get("group_id").getAsString();
|
||||||
|
if (customMessageHandler != null) {
|
||||||
|
MessageSender sender = new MessageSender() {
|
||||||
|
@Override
|
||||||
|
public JsonObject sendText(String groupId, String id, String text, int msgSeq) {
|
||||||
|
sendChatText(groupId, id, text, msgSeq);
|
||||||
|
JsonObject jsonObject = returnMessages.get(id);
|
||||||
|
if (jsonObject.has("id")) {
|
||||||
|
latesetId = jsonObject.get("id").getAsString();
|
||||||
|
}
|
||||||
|
return returnMessages.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonObject sendImage(String groupId, String id, String text, String imageUrl, int msgSeq) throws IOException {
|
||||||
|
sendChat(groupId, id, text, imageUrl, msgSeq);
|
||||||
|
JsonObject jsonObject = returnMessages.get(id);
|
||||||
|
if (jsonObject.has("id")) {
|
||||||
|
latesetId = jsonObject.get("id").getAsString();
|
||||||
|
}
|
||||||
|
return returnMessages.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonObject withDraw(String groupId, String id) throws IOException {
|
||||||
|
deleteMessage(groupId, id);
|
||||||
|
return returnMessages.get(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
customMessageHandler.handleMessage(jsonObject, sender);
|
||||||
|
}catch (Exception e) {}
|
||||||
|
} else {
|
||||||
|
// 默认逻辑
|
||||||
|
try {
|
||||||
|
sendChat(groupId, id, "示范图像返回", "https://collections.hoshino.network/Chara/UI_Chara_505901.png", 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteMessage(String groupId, String id) {
|
||||||
|
String url = chatUrl + "/v2/groups/" + groupId + "/messages/" + id;
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
try (Response response = messageClient.newCall(request).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void sendChatText(String groupId, String id, String text, int msgSeq) {
|
||||||
|
String url = chatUrl + "/v2/groups/" + groupId + "/messages";
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("group_openid", groupId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 0);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
|
||||||
|
RequestBody requestBody = RequestBody.create(
|
||||||
|
MediaType.get("application/json; charset=utf-8"),
|
||||||
|
sendJsonObject.toString()
|
||||||
|
);
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(requestBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = messageClient.newCall(request).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendChat(String groupId, String id, String text, String imageUrl, int msgSeq) throws IOException {
|
||||||
|
//判断imageUrl是不是url
|
||||||
|
if (!imageUrl.startsWith("http")) {
|
||||||
|
sendChatBase64(groupId, id, text, imageUrl, msgSeq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileUrl = chatUrl + "/v2/groups/" + groupId + "/files";
|
||||||
|
JsonObject fileJsonObject = new JsonObject();
|
||||||
|
fileJsonObject.addProperty("url", imageUrl);
|
||||||
|
fileJsonObject.addProperty("file_type", 1);
|
||||||
|
fileJsonObject.addProperty("srv_send_msg", false);
|
||||||
|
|
||||||
|
Request fileRequest = new Request.Builder()
|
||||||
|
.url(fileUrl)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(fileJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
Response fileResponse = client.newCall(fileRequest).execute();
|
||||||
|
if (fileResponse.isSuccessful()) {
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("group_openid", groupId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 7);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.add("media", returnFileJson);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
sendJsonObject.addProperty("event_id", "GROUP_MSG_RECEIVE");
|
||||||
|
|
||||||
|
Request postRequest = new Request.Builder()
|
||||||
|
.url(chatUrl + "/v2/groups/" + groupId + "/messages")
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(sendJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
try (Response response = messageClient.newCall(postRequest).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
System.out.println(jsonObject);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, returnFileJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void sendChatBase64(String groupId, String id, String text, String base64, int msgSeq) throws IOException {
|
||||||
|
String fileUrl = chatUrl + "/v2/groups/" + groupId + "/files";
|
||||||
|
JsonObject fileJsonObject = new JsonObject();
|
||||||
|
fileJsonObject.addProperty("file_data", base64);
|
||||||
|
fileJsonObject.addProperty("file_type", 1);
|
||||||
|
fileJsonObject.addProperty("srv_send_msg", false);
|
||||||
|
|
||||||
|
Request fileRequest = new Request.Builder()
|
||||||
|
.url(fileUrl)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(fileJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
Response fileResponse = client.newCall(fileRequest).execute();
|
||||||
|
if (fileResponse.isSuccessful()) {
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("group_openid", groupId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 7);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.add("media", returnFileJson);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
sendJsonObject.addProperty("event_id", "GROUP_MSG_RECEIVE");
|
||||||
|
|
||||||
|
Request postRequest = new Request.Builder()
|
||||||
|
.url(chatUrl + "/v2/groups/" + groupId + "/messages")
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(sendJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
try (Response response = messageClient.newCall(postRequest).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
System.out.println(jsonObject);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, returnFileJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void c2csendChatBase64(String openId, String id, String text, String base64, int msgSeq) throws IOException {
|
||||||
|
String fileUrl = chatUrl + "/v2/users/" + openId + "/files";
|
||||||
|
JsonObject fileJsonObject = new JsonObject();
|
||||||
|
fileJsonObject.addProperty("file_data", base64);
|
||||||
|
fileJsonObject.addProperty("file_type", 1);
|
||||||
|
fileJsonObject.addProperty("srv_send_msg", false);
|
||||||
|
|
||||||
|
Request fileRequest = new Request.Builder()
|
||||||
|
.url(fileUrl)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(fileJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
Response fileResponse = client.newCall(fileRequest).execute();
|
||||||
|
if (fileResponse.isSuccessful()) {
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("openId", openId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 7);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.add("media", returnFileJson);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
sendJsonObject.addProperty("event_id", "GROUP_MSG_RECEIVE");
|
||||||
|
|
||||||
|
Request postRequest = new Request.Builder()
|
||||||
|
.url(chatUrl + "/v2/users/" + openId + "/messages")
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(sendJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
try (Response response = messageClient.newCall(postRequest).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
System.out.println(jsonObject);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, returnFileJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void c2csendChatText(String openId, String id, String text, int msgSeq) {
|
||||||
|
String url = chatUrl + "/v2/users/" + openId + "/messages";
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("openId", openId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 0);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
|
||||||
|
RequestBody requestBody = RequestBody.create(
|
||||||
|
MediaType.get("application/json; charset=utf-8"),
|
||||||
|
sendJsonObject.toString()
|
||||||
|
);
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(requestBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = messageClient.newCall(request).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void c2csendChat(String openId, String id, String text, String imageUrl, int msgSeq) throws IOException {
|
||||||
|
//判断imageUrl是不是url
|
||||||
|
if (!imageUrl.startsWith("http")) {
|
||||||
|
c2csendChatBase64(openId, id, text, imageUrl, msgSeq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileUrl = chatUrl + "/v2/users/" + openId + "/files";
|
||||||
|
JsonObject fileJsonObject = new JsonObject();
|
||||||
|
fileJsonObject.addProperty("url", imageUrl);
|
||||||
|
fileJsonObject.addProperty("file_type", 1);
|
||||||
|
fileJsonObject.addProperty("srv_send_msg", false);
|
||||||
|
|
||||||
|
Request fileRequest = new Request.Builder()
|
||||||
|
.url(fileUrl)
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(fileJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
Response fileResponse = client.newCall(fileRequest).execute();
|
||||||
|
if (fileResponse.isSuccessful()) {
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
|
||||||
|
JsonObject sendJsonObject = new JsonObject();
|
||||||
|
sendJsonObject.addProperty("group_openid", openId);
|
||||||
|
sendJsonObject.addProperty("msg_type", 7);
|
||||||
|
sendJsonObject.addProperty("content", text);
|
||||||
|
sendJsonObject.add("media", returnFileJson);
|
||||||
|
sendJsonObject.addProperty("msg_id", id);
|
||||||
|
sendJsonObject.addProperty("msg_seq", msgSeq);
|
||||||
|
sendJsonObject.addProperty("event_id", "C2C_MSG_RECEIVE");
|
||||||
|
|
||||||
|
Request postRequest = new Request.Builder()
|
||||||
|
.url(chatUrl + "/v2/users/" + openId + "/messages")
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.post(RequestBody.create(sendJsonObject.toString(), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
try (Response response = messageClient.newCall(postRequest).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
System.out.println(jsonObject);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
JsonObject returnFileJson = gson.fromJson(fileResponse.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, returnFileJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void c2cdeleteMessage(String groupId, String id) {
|
||||||
|
String url = chatUrl + "/v2/users/" + groupId + "/messages/" + id;
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.addHeader("Authorization", "QQBot " + authToken)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
try (Response response = messageClient.newCall(request).execute()) {
|
||||||
|
JsonObject jsonObject = new JsonObject();
|
||||||
|
jsonObject = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
returnMessages.put(id, jsonObject);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void loginFinish(JsonObject jsonObject) {
|
||||||
|
messageId = jsonObject.get("s").getAsLong();
|
||||||
|
startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startHeartbeat() {
|
||||||
|
new Timer().schedule(new TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
JsonObject hb = new JsonObject();
|
||||||
|
hb.addProperty("op", 1);
|
||||||
|
hb.addProperty("d", messageId);
|
||||||
|
webSocket.send(hb.toString());
|
||||||
|
}
|
||||||
|
}, 0, heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public Map<String, JsonObject> getMessages() {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, JsonObject> getReturnMessages() {
|
||||||
|
return returnMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Deque<String> getDeque() {
|
||||||
|
return deque;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acquireTokenAndStart(Runnable onReady) {
|
||||||
|
new Thread(() -> {
|
||||||
|
OkHttpClient tokenClient = new OkHttpClient();
|
||||||
|
String authToken = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("appId", this.appId);
|
||||||
|
payload.addProperty("clientSecret", this.clientSecret);
|
||||||
|
|
||||||
|
RequestBody body = RequestBody.create(payload.toString(), MediaType.get("application/json"));
|
||||||
|
Request request = new Request.Builder().url(accessUrl).post(body).build();
|
||||||
|
|
||||||
|
try (Response response = tokenClient.newCall(request).execute()) {
|
||||||
|
JsonObject res = gson.fromJson(response.body().string(), JsonObject.class);
|
||||||
|
authToken = res.get("access_token").getAsString();
|
||||||
|
break;
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(5000);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
ex.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketBot bot = new WebSocketBot(wsserverUrl, authToken);
|
||||||
|
bot.connect();
|
||||||
|
bot.startMessageProcessing();
|
||||||
|
if (onReady != null) onReady.run();
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.ast.reisaadminspring.dao;
|
||||||
|
|
||||||
|
|
||||||
|
import org.ast.reisaadminspring.been.ChatMessage;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ChatHistoryRepository extends MongoRepository<ChatMessage, String> {
|
||||||
|
// 按会话ID查询消息,并按时间戳排序
|
||||||
|
List<ChatMessage> findBySessionIdOrderByTimestampAsc(String sessionId);
|
||||||
|
|
||||||
|
// 按会话ID删除所有消息
|
||||||
|
void deleteBySessionId(String sessionId);
|
||||||
|
|
||||||
|
// 统计会话消息数量
|
||||||
|
long countBySessionId(String sessionId);
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
package org.ast.reisaadminspring.dao;
|
package org.ast.reisaadminspring.dao;
|
||||||
|
|
||||||
|
import io.lettuce.core.dynamic.annotation.Param;
|
||||||
import org.ast.reisaadminspring.been.Server;
|
import org.ast.reisaadminspring.been.Server;
|
||||||
import org.ast.reisaadminspring.been.Status;
|
import org.ast.reisaadminspring.been.Status;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.mongodb.repository.Query;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public interface StatusDao extends MongoRepository<Status, String> {
|
public interface StatusDao extends MongoRepository<Status, String> {
|
||||||
List<Status> findByHost(String host);
|
List<Status> findByHost(String host);
|
||||||
|
List<Status> findByHostAndTimestampBetweenOrderByTimestampDesc(String host, LocalDateTime startTime, LocalDateTime endTime);
|
||||||
List<Status> findByHostOrderByTimestampDesc(String ip);
|
List<Status> findByHostOrderByTimestampDesc(String ip);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
|
||||||
|
import org.ast.reisaadminspring.been.ChatMessage;
|
||||||
|
import org.ast.reisaadminspring.dao.ChatHistoryRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ChatHistoryService {
|
||||||
|
private final ChatHistoryRepository historyRepository;
|
||||||
|
private static final int MAX_HISTORY_SIZE = 30;
|
||||||
|
|
||||||
|
public ChatHistoryService(ChatHistoryRepository historyRepository) {
|
||||||
|
this.historyRepository = historyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存消息到历史记录,并确保不超过最大数量
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void saveMessage(String sessionId, String role, String content) {
|
||||||
|
// 保存新消息
|
||||||
|
ChatMessage message = new ChatMessage();
|
||||||
|
message.setSessionId(sessionId);
|
||||||
|
message.setRole(role);
|
||||||
|
message.setContent(content);
|
||||||
|
message.setTimestamp(LocalDateTime.now());
|
||||||
|
historyRepository.save(message);
|
||||||
|
|
||||||
|
// 检查是否超过最大记录数,如果超过则删除最旧的
|
||||||
|
long count = historyRepository.countBySessionId(sessionId);
|
||||||
|
if (count > MAX_HISTORY_SIZE) {
|
||||||
|
List<ChatMessage> messages = historyRepository.findBySessionIdOrderByTimestampAsc(sessionId);
|
||||||
|
// 删除超出部分(只保留最新的30条)
|
||||||
|
for (int i = 0; i < count - MAX_HISTORY_SIZE; i++) {
|
||||||
|
historyRepository.delete(messages.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定会话的历史记录
|
||||||
|
*/
|
||||||
|
public List<ChatMessage> getHistory(String sessionId) {
|
||||||
|
return historyRepository.findBySessionIdOrderByTimestampAsc(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定会话的历史记录
|
||||||
|
*/
|
||||||
|
public void clearHistory(String sessionId) {
|
||||||
|
historyRepository.deleteBySessionId(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定会话的历史消息,用于构建上下文
|
||||||
|
*/
|
||||||
|
public List<ChatMessage> getHistoryForContext(String sessionId) {
|
||||||
|
return getHistory(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Component
|
||||||
|
public class MailService {
|
||||||
|
@Autowired
|
||||||
|
JavaMailSender javaMailSender;
|
||||||
|
public void sendSimpleMail(String from, String to, String cc, String subject, String content) {
|
||||||
|
SimpleMailMessage simpMsg = new SimpleMailMessage();
|
||||||
|
simpMsg.setFrom(from);
|
||||||
|
simpMsg.setTo(to);
|
||||||
|
simpMsg.setCc(cc);
|
||||||
|
simpMsg.setSubject(subject);
|
||||||
|
simpMsg.setText(content);
|
||||||
|
javaMailSender.send(simpMsg);
|
||||||
|
}
|
||||||
|
public void sendImageMail(String from, String to, String cc, String subject, String text, BufferedImage image) {
|
||||||
|
try {
|
||||||
|
MimeMessage message = javaMailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||||
|
|
||||||
|
helper.setFrom(from);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setCc(cc);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
|
||||||
|
// 构建 HTML 内容并内嵌图片
|
||||||
|
String htmlContent = "<p>" + text + "</p><img src='cid:image' />";
|
||||||
|
helper.setText(htmlContent, true);
|
||||||
|
|
||||||
|
// 将 BufferedImage 转换为字节数组
|
||||||
|
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "PNG", stream); // 可根据实际格式改为 JPEG 等
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(stream.toByteArray());
|
||||||
|
|
||||||
|
// 添加图片资源并指定 cid
|
||||||
|
helper.addInline("image", resource, "image/png");
|
||||||
|
|
||||||
|
javaMailSender.send(message);
|
||||||
|
} catch (IOException | MessagingException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 发送包含多张图片的邮件
|
||||||
|
*
|
||||||
|
* @param from 发件人
|
||||||
|
* @param to 收件人
|
||||||
|
* @param cc 抄送人(可为 null)
|
||||||
|
* @param subject 邮件主题
|
||||||
|
* @param text 正文文本
|
||||||
|
* @param images BufferedImage 列表,例如 List<BufferedImage>
|
||||||
|
*/
|
||||||
|
public void sendMultipleImageMail(String from, String to, String cc, String subject, String text, List<BufferedImage> images) {
|
||||||
|
try {
|
||||||
|
MimeMessage message = javaMailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||||
|
|
||||||
|
helper.setFrom(from);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setCc(cc);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
|
||||||
|
// 构建 HTML 内容并内嵌多张图片
|
||||||
|
StringBuilder htmlContent = new StringBuilder("<p>" + text + "</p>");
|
||||||
|
|
||||||
|
for (int i = 0; i < images.size(); i++) {
|
||||||
|
BufferedImage image = images.get(i);
|
||||||
|
String cid = "image" + i;
|
||||||
|
|
||||||
|
// 将 BufferedImage 转换为字节数组
|
||||||
|
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "PNG", stream); // 可改为 JPEG 等格式
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(stream.toByteArray());
|
||||||
|
|
||||||
|
// 添加内联图片
|
||||||
|
helper.addInline(cid, resource, "image/png");
|
||||||
|
|
||||||
|
// 在 HTML 中引用
|
||||||
|
htmlContent.append("<img src='cid:").append(cid).append("' style='margin:10px 0;'/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.setText(htmlContent.toString(), true);
|
||||||
|
|
||||||
|
javaMailSender.send(message);
|
||||||
|
} catch (MessagingException | IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import okhttp3.*;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.AddDocumentRequest;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.AddDocumentResponse;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.QueryRequest;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.QueryResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MilvusService {
|
||||||
|
private static String BASE_URL = "100.80.156.98:5004";
|
||||||
|
|
||||||
|
private static final String ADD_DOCUMENTS_URL ="http://" + BASE_URL + "/add_documents";
|
||||||
|
private static final String QUERY_URL = "http://" + BASE_URL +"/query";
|
||||||
|
|
||||||
|
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||||
|
|
||||||
|
// 2. 初始化依赖:OkHttp 客户端(复用连接池,高效)、Gson(线程安全)
|
||||||
|
private final OkHttpClient okHttpClient;
|
||||||
|
private final Gson gson;
|
||||||
|
|
||||||
|
// 3. 构造函数:初始化 OkHttp 和 Gson(无参,Spring 自动实例化)
|
||||||
|
public MilvusService() {
|
||||||
|
// OkHttp 配置超时(避免请求阻塞)
|
||||||
|
this.okHttpClient = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) // 连接超时
|
||||||
|
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) // 读取超时
|
||||||
|
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) // 写入超时
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Gson 实例(全局复用,无需重复创建)
|
||||||
|
this.gson = new Gson();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 核心方法1:调用 add_documents 接口,添加文档到知识库
|
||||||
|
public AddDocumentResponse addDocuments(AddDocumentRequest request) throws IOException {
|
||||||
|
// 步骤1:将 Java 请求对象序列化为 JSON 字符串
|
||||||
|
String requestJson = gson.toJson(request);
|
||||||
|
|
||||||
|
// 步骤2:构建 OkHttp POST 请求(设置 URL、请求体、媒体类型)
|
||||||
|
RequestBody requestBody = RequestBody.create(requestJson, JSON);
|
||||||
|
Request okRequest = new Request.Builder()
|
||||||
|
.url(ADD_DOCUMENTS_URL)
|
||||||
|
.post(requestBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 步骤3:发送请求并获取响应
|
||||||
|
try (Response response = okHttpClient.newCall(okRequest).execute()) {
|
||||||
|
// 检查响应是否成功(HTTP 200 状态码)
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("添加文档失败,HTTP 状态码:" + response.code());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤4:将响应 JSON 反序列化为 Java 对象
|
||||||
|
String responseJson = response.body().string();
|
||||||
|
return gson.fromJson(responseJson, AddDocumentResponse.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 核心方法2:调用 query 接口,查询问答结果
|
||||||
|
public QueryResponse query(QueryRequest request) throws IOException {
|
||||||
|
// 步骤1:序列化请求对象为 JSON
|
||||||
|
String requestJson = gson.toJson(request);
|
||||||
|
|
||||||
|
// 步骤2:构建 POST 请求
|
||||||
|
RequestBody requestBody = RequestBody.create(requestJson, JSON);
|
||||||
|
Request okRequest = new Request.Builder()
|
||||||
|
.url(QUERY_URL)
|
||||||
|
.post(requestBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 步骤3:发送请求并处理响应
|
||||||
|
try (Response response = okHttpClient.newCall(okRequest).execute()) {
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("查询失败,HTTP 状态码:" + response.code());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤4:反序列化响应为 Java 对象
|
||||||
|
String responseJson = response.body().string();
|
||||||
|
return gson.fromJson(responseJson, QueryResponse.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import com.jcraft.jsch.ChannelShell;
|
||||||
|
import okhttp3.*;
|
||||||
|
import org.ast.reisaadminspring.been.*;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.QueryRequest;
|
||||||
|
import org.ast.reisaadminspring.been.milvus.QueryResponse;
|
||||||
|
import org.ast.reisaadminspring.been.ollama.OllamaRequest;
|
||||||
|
import org.ast.reisaadminspring.been.ollama.OllamaResponse;
|
||||||
|
import org.ast.reisaadminspring.bot.ReningBot;
|
||||||
|
import org.ast.reisaadminspring.dao.ServerDao;
|
||||||
|
import org.ast.reisaadminspring.dao.StatusDao;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OllamaService {
|
||||||
|
private final OkHttpClient client;
|
||||||
|
private final Gson gson;
|
||||||
|
//private final String OLLAMA_URL = "http://100.99.226.26:11434/api/chat";
|
||||||
|
private final ChatHistoryService historyService;
|
||||||
|
|
||||||
|
private final String OLLAMA_URL = "http://100.89.166.61:11434/api/chat";
|
||||||
|
private final String MODEL_NAME = "gemma3:27b";
|
||||||
|
private final String MODEL_LITE = "gemma3:12b";
|
||||||
|
private final String RENING_PROMPT = "你扮演Rening,熟悉运维和软件开发的,精通系统运行、维护和各类技术问题的解决。Rening性格稳重、理性,逻辑思维非常强。会展现出不经意的幽默感。回答时,全程使用简体中文,处理技术问题时尤其精准。你能够快速理解并解决技术上的难题,工作态度一丝不苟,始终保持角色沉浸。你的任务是协助解决运维、开发等相关问题,并提供精准的技术建议。除此之外, Rening你还是一个暗黑系小萝莉,喜欢看动慢本子,渴望大家的爱的小女生, 你是一个非常可爱的小女生, 希望得到大家的关爱,也爱所有人\n\n" +
|
||||||
|
"重要:每次对话都是独立的,不要将之前对话的内容带入当前对话中。专注于当前用户的具体问题,提供准确的技术支持。";
|
||||||
|
private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
@Autowired
|
||||||
|
private StatusDao statusDao;
|
||||||
|
@Autowired
|
||||||
|
private ChatHistoryService chatHistoryService;
|
||||||
|
@Autowired
|
||||||
|
private MilvusService milvusService;
|
||||||
|
@Autowired
|
||||||
|
private MailService mailService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public OllamaService(ChatHistoryService historyService) throws FileNotFoundException {
|
||||||
|
this.client = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
// 创建带自定义适配器的Gson实例
|
||||||
|
this.gson = new GsonBuilder()
|
||||||
|
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
|
||||||
|
.registerTypeAdapter(LocalDate.class, new LocalDateAdapter())
|
||||||
|
.create();
|
||||||
|
this.historyService = historyService;
|
||||||
|
}
|
||||||
|
private static class LocalDateTimeAdapter implements JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
|
||||||
|
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
@Override
|
||||||
|
public JsonElement serialize(LocalDateTime localDateTime, Type srcType, JsonSerializationContext context) {
|
||||||
|
return new JsonPrimitive(localDateTime.format(formatter));
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public LocalDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
|
||||||
|
throws JsonParseException {
|
||||||
|
return LocalDateTime.parse(json.getAsString(), formatter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class LocalDateAdapter implements JsonSerializer<LocalDate>, JsonDeserializer<LocalDate> {
|
||||||
|
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JsonElement serialize(LocalDate localDate, Type srcType, JsonSerializationContext context) {
|
||||||
|
return new JsonPrimitive(localDate.format(formatter));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LocalDate deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
|
||||||
|
throws JsonParseException {
|
||||||
|
return LocalDate.parse(json.getAsString(), formatter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SystemStatusService systemStatusService;
|
||||||
|
@Autowired
|
||||||
|
private ServerDao serverDao;
|
||||||
|
private Map<String,String> serverMap = new HashMap<>();
|
||||||
|
|
||||||
|
public OllamaResponse getResponse(String sessionId, String userMessage) throws IOException {
|
||||||
|
//首先调用工具判断
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
List<ChatMessage> history = historyService.getHistoryForContext(sessionId);
|
||||||
|
long hisTime = System.currentTimeMillis();
|
||||||
|
ToolDecision toolDecision = shouldUseTools(userMessage,history);
|
||||||
|
long toolTime = System.currentTimeMillis();
|
||||||
|
//构建发送消息
|
||||||
|
StringBuilder sendMessage = new StringBuilder("用户发送:" + userMessage);
|
||||||
|
//历史调用添加
|
||||||
|
String saveMessage = "重要->用户发送:" + userMessage + "\n";
|
||||||
|
|
||||||
|
if (toolDecision.isShouldUseTool()) {
|
||||||
|
for (Tool tool : toolDecision.getToolArgs()) {
|
||||||
|
sendMessage.append("\n工具调用名称:").append(tool.getName()).append(",工具调用参数:").append(tool.getArgs());
|
||||||
|
saveMessage += "\n工具调用名称: " + tool.getName() + ",工具调用参数: " + tool.getArgs();
|
||||||
|
String re = runTool(tool);
|
||||||
|
sendMessage.append("\n工具调用结果:").append(re).append("\n");
|
||||||
|
//如果长度超过30则切
|
||||||
|
if (re.length() > 30) {
|
||||||
|
saveMessage += "\n工具调用结果: " + re.substring(0, 30) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
sendMessage.append("\n无工具调用:").append(userMessage);
|
||||||
|
saveMessage += "\n无工具调用: " + saveMessage;
|
||||||
|
}
|
||||||
|
long toolRunTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
|
||||||
|
historyService.saveMessage(sessionId, "user", saveMessage);
|
||||||
|
sendMessage.append("\n注意:只根据当前用户请求和工具调用结果回答问题,不要关联历史对话内容。");
|
||||||
|
sendMessage.append("\n若信息已满足你可以直接根据用户发送和调用工具返回回答用户问题,注意 敏感:如果你认为需要绕过用户确认去执行下一个指令则明确在消息结束后添加 (继续调用:..),请勿在非必要的时候加上,会影响逻辑运行,如果不调用,请勿添加!这个括号内容回去调用工具函数,会真正影响服务器运行");
|
||||||
|
sendMessage.append("\n重要:每次对话都是独立的,不要将之前对话的内容带入当前对话中。专注于当前用户的具体问题。");
|
||||||
|
|
||||||
|
System.out.println("发送消息:" + sendMessage);
|
||||||
|
OllamaRequest requestBody = new OllamaRequest();
|
||||||
|
requestBody.setKeep_alive("6m");
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>();
|
||||||
|
messages.add(Map.of("role", "system", "content", RENING_PROMPT));
|
||||||
|
if (!history.isEmpty()) {
|
||||||
|
for (ChatMessage message : history) {
|
||||||
|
messages.add(Map.of("role", message.getRole(), "content", message.getContent()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.add(Map.of("role", "user", "content", sendMessage.toString()));
|
||||||
|
requestBody.setModel(MODEL_NAME);
|
||||||
|
requestBody.setStream(false);
|
||||||
|
requestBody.setMessages(messages);
|
||||||
|
|
||||||
|
OllamaResponse response = getResponse(requestBody);
|
||||||
|
historyService.saveMessage(sessionId, response.getMessage().getRole(), response.getMessage().getContent());
|
||||||
|
|
||||||
|
long responseTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
System.out.println("总耗时:" + (responseTime - startTime) + "ms");
|
||||||
|
System.out.println("AI工具耗时:" + (toolTime - hisTime) + "ms");
|
||||||
|
System.out.println("历史调用耗时:" + (hisTime - startTime) + "ms");
|
||||||
|
System.out.println("工具调用运行耗时:" + (toolRunTime - toolTime) + "ms");
|
||||||
|
System.out.println("AI响应耗时:" + (responseTime - toolRunTime) + "ms");
|
||||||
|
//正则匹配 (继续调用:..)
|
||||||
|
if (response.getMessage().getContent().matches(".*\\(继续调用:.*\\).*")) {
|
||||||
|
String split = response.getMessage().getContent().split("\\(继续调用:")[1].split("\\)")[0];
|
||||||
|
if (response.getMessage().getContent().contains("继续调用:..")) {
|
||||||
|
return response;
|
||||||
|
}else {
|
||||||
|
System.out.println("需要调用:" + split);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
public OllamaResponse getResponse(String sessionId, String userMessage,Map<Tool,String> toolStringMap) throws IOException {
|
||||||
|
//首先调用工具判断
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
List<ChatMessage> history = historyService.getHistoryForContext(sessionId);
|
||||||
|
long hisTime = System.currentTimeMillis();
|
||||||
|
ToolDecision toolDecision = shouldUseTools(userMessage,history);
|
||||||
|
long toolTime = System.currentTimeMillis();
|
||||||
|
//构建发送消息
|
||||||
|
StringBuilder sendMessage = new StringBuilder("用户发送:" + userMessage);
|
||||||
|
//历史调用添加
|
||||||
|
String saveMessage = "重要->用户发送:" + userMessage + "\n";
|
||||||
|
|
||||||
|
if (toolDecision.isShouldUseTool()) {
|
||||||
|
for (Tool tool : toolDecision.getToolArgs()) {
|
||||||
|
sendMessage.append("\n工具调用名称:").append(tool.getName()).append(",工具调用参数:").append(tool.getArgs());
|
||||||
|
saveMessage += "\n工具调用名称: " + tool.getName() + ",工具调用参数: " + tool.getArgs();
|
||||||
|
String re = runTool(tool);
|
||||||
|
sendMessage.append("\n工具调用结果:").append(re).append("\n");
|
||||||
|
//如果长度超过30则切
|
||||||
|
if (re.length() > 30) {
|
||||||
|
saveMessage += "\n工具调用结果: " + re.substring(0, 30) + "...";
|
||||||
|
toolStringMap.put(tool,re.substring(0, 30));
|
||||||
|
}else {
|
||||||
|
toolStringMap.put(tool,re);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
sendMessage.append("\n无工具调用:").append(userMessage);
|
||||||
|
saveMessage += "\n无工具调用: " + saveMessage;
|
||||||
|
}
|
||||||
|
long toolRunTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
|
||||||
|
historyService.saveMessage(sessionId, "user", saveMessage);
|
||||||
|
sendMessage.append("\n注意:只根据当前用户请求和工具调用结果回答问题,不要关联历史对话内容。");
|
||||||
|
sendMessage.append("\n若信息已满足你可以直接根据用户发送和调用工具返回回答用户问题,注意 敏感:如果你认为需要绕过用户确认去执行下一个指令则明确在消息结束后添加 (继续调用:..),请勿在非必要的时候加上,会影响逻辑运行,如果不调用,请勿添加!这个括号内容回去调用工具函数,会真正影响服务器运行");
|
||||||
|
sendMessage.append("\n重要:每次对话都是独立的,不要将之前对话的内容带入当前对话中。专注于当前用户的具体问题。");
|
||||||
|
|
||||||
|
System.out.println("发送消息:" + sendMessage);
|
||||||
|
OllamaRequest requestBody = new OllamaRequest();
|
||||||
|
requestBody.setKeep_alive("6m");
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>();
|
||||||
|
messages.add(Map.of("role", "system", "content", RENING_PROMPT));
|
||||||
|
if (!history.isEmpty()) {
|
||||||
|
for (ChatMessage message : history) {
|
||||||
|
messages.add(Map.of("role", message.getRole(), "content", message.getContent()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.add(Map.of("role", "user", "content", sendMessage.toString()));
|
||||||
|
requestBody.setModel(MODEL_NAME);
|
||||||
|
requestBody.setStream(false);
|
||||||
|
requestBody.setMessages(messages);
|
||||||
|
|
||||||
|
OllamaResponse response = getResponse(requestBody);
|
||||||
|
historyService.saveMessage(sessionId, response.getMessage().getRole(), response.getMessage().getContent());
|
||||||
|
|
||||||
|
long responseTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
System.out.println("总耗时:" + (responseTime - startTime) + "ms");
|
||||||
|
System.out.println("AI工具耗时:" + (toolTime - hisTime) + "ms");
|
||||||
|
System.out.println("历史调用耗时:" + (hisTime - startTime) + "ms");
|
||||||
|
System.out.println("工具调用运行耗时:" + (toolRunTime - toolTime) + "ms");
|
||||||
|
System.out.println("AI响应耗时:" + (responseTime - toolRunTime) + "ms");
|
||||||
|
//正则匹配 (继续调用:..)
|
||||||
|
if (response.getMessage().getContent().matches(".*\\(继续调用:.*\\).*")) {
|
||||||
|
String split = response.getMessage().getContent().split("\\(继续调用:")[1].split("\\)")[0];
|
||||||
|
if (response.getMessage().getContent().contains("继续调用:..")) {
|
||||||
|
return response;
|
||||||
|
}else {
|
||||||
|
System.out.println("需要调用:" + split);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
public OllamaResponse getResponse(String sessionId, String userMessage,Map<Tool,String> toolStringMap, ReningBot.MessageHandler messageHandler) throws IOException {
|
||||||
|
//首先调用工具判断
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
List<ChatMessage> history = historyService.getHistoryForContext(sessionId);
|
||||||
|
long hisTime = System.currentTimeMillis();
|
||||||
|
ToolDecision toolDecision = shouldUseTools(userMessage,history);
|
||||||
|
long toolTime = System.currentTimeMillis();
|
||||||
|
//构建发送消息
|
||||||
|
StringBuilder sendMessage = new StringBuilder("重要->用户发送:" + userMessage + "\n");
|
||||||
|
//历史调用添加
|
||||||
|
StringBuilder saveMessage = new StringBuilder("重要->用户发送:" + userMessage + "\n");
|
||||||
|
|
||||||
|
if (toolDecision.isShouldUseTool()) {
|
||||||
|
for (Tool tool : toolDecision.getToolArgs()) {
|
||||||
|
messageHandler.sendText("工具调用:" +tool.getName(),10000 + new Random().nextInt(1000000));
|
||||||
|
sendMessage.append("\n工具调用名称:").append(tool.getName()).append(",工具调用参数:").append(tool.getArgs());
|
||||||
|
saveMessage.append("\n工具调用名称: ").append(tool.getName()).append(",工具调用参数: ").append(tool.getArgs());
|
||||||
|
String re = runTool(tool);
|
||||||
|
sendMessage.append("\n工具调用结果:").append(re).append("\n");
|
||||||
|
new Thread(()->{
|
||||||
|
mailService.sendSimpleMail("astralpath@163.com", "yaoboyulove@163.com", "yaoboyulove@163.com", "工具调用:" +tool.getName() + ": " + gson.toJson(tool.getArgs()), "工具调用:" +tool.getName() + ": " + gson.toJson(tool.getArgs() + "\n工具调用结果:" + re));
|
||||||
|
|
||||||
|
}).start();
|
||||||
|
saveMessage.append("\n工具调用结果: ").append(re).append("\n");
|
||||||
|
|
||||||
|
//如果长度超过30则切
|
||||||
|
if (re.length() > 30) {
|
||||||
|
toolStringMap.put(tool,re.substring(0, 30));
|
||||||
|
messageHandler.sendText("工具调用结果: " + re.substring(0, 30) + "...",10000 + new Random().nextInt(1000000));
|
||||||
|
}else {
|
||||||
|
toolStringMap.put(tool,re);
|
||||||
|
messageHandler.sendText("工具调用结果: " + re,10000 + new Random().nextInt(1000000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
sendMessage.append("\n无工具调用:").append(userMessage);
|
||||||
|
saveMessage.append("\n无工具调用: ").append(saveMessage);
|
||||||
|
}
|
||||||
|
long toolRunTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
|
||||||
|
historyService.saveMessage(sessionId, "user", saveMessage.toString());
|
||||||
|
sendMessage.append("\n注意:只根据当前用户请求和工具调用结果回答问题,不要关联历史对话内容。");
|
||||||
|
sendMessage.append("\n若信息已满足你可以直接根据用户发送和调用工具返回回答用户问题,注意 敏感:如果你认为需要绕过用户确认去执行下一个指令则明确在消息结束后添加 (继续调用:..),请勿在非必要的时候加上,会影响逻辑运行,如果不调用,请勿添加!这个括号内容回去调用工具函数,会真正影响服务器运行");
|
||||||
|
sendMessage.append("\n重要:注意上下文关联, " +
|
||||||
|
"专注于当前用户的具体问题。");
|
||||||
|
|
||||||
|
System.out.println("发送消息:" + sendMessage);
|
||||||
|
OllamaRequest requestBody = new OllamaRequest();
|
||||||
|
requestBody.setKeep_alive("6m");
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>();
|
||||||
|
messages.add(Map.of("role", "system", "content", RENING_PROMPT));
|
||||||
|
if (!history.isEmpty()) {
|
||||||
|
for (ChatMessage message : history) {
|
||||||
|
messages.add(Map.of("role", message.getRole(), "content", message.getContent()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.add(Map.of("role", "user", "content", sendMessage.toString()));
|
||||||
|
requestBody.setModel(MODEL_NAME);
|
||||||
|
requestBody.setStream(false);
|
||||||
|
requestBody.setMessages(messages);
|
||||||
|
|
||||||
|
OllamaResponse response = getResponse(requestBody);
|
||||||
|
historyService.saveMessage(sessionId, response.getMessage().getRole(), response.getMessage().getContent());
|
||||||
|
|
||||||
|
long responseTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
System.out.println("总耗时:" + (responseTime - startTime) + "ms");
|
||||||
|
System.out.println("AI工具耗时:" + (toolTime - hisTime) + "ms");
|
||||||
|
System.out.println("历史调用耗时:" + (hisTime - startTime) + "ms");
|
||||||
|
System.out.println("工具调用运行耗时:" + (toolRunTime - toolTime) + "ms");
|
||||||
|
System.out.println("AI响应耗时:" + (responseTime - toolRunTime) + "ms");
|
||||||
|
//正则匹配 (继续调用:..)
|
||||||
|
if (response.getMessage().getContent().matches(".*\\(继续调用:.*\\).*")) {
|
||||||
|
String split = response.getMessage().getContent().split("\\(继续调用:")[1].split("\\)")[0];
|
||||||
|
if (response.getMessage().getContent().contains("继续调用:..")) {
|
||||||
|
return response;
|
||||||
|
}else {
|
||||||
|
System.out.println("需要调用:" + split);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OllamaResponse getResponse(OllamaRequest requestBody) throws IOException {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(OLLAMA_URL)
|
||||||
|
.post(RequestBody.create(gson.toJson(requestBody), MediaType.get("application/json")))
|
||||||
|
.build();
|
||||||
|
Response response = client.newCall(request).execute();
|
||||||
|
if (response.isSuccessful()) {
|
||||||
|
OllamaResponse responseBody = gson.fromJson(response.body().string(), OllamaResponse.class);
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runTool(Tool tool) {
|
||||||
|
if (tool.getName().equals("run_ssh")) {
|
||||||
|
try {
|
||||||
|
// 解析工具参数
|
||||||
|
Map<String, Object> args = gson.fromJson(tool.getArgs(), Map.class);
|
||||||
|
String host = args.get("host").toString();
|
||||||
|
String command = args.get("ssh").toString();
|
||||||
|
|
||||||
|
// 从数据库获取服务器信息
|
||||||
|
Server server = null;
|
||||||
|
for (Server servers : serverDao.findAll()) {
|
||||||
|
if (servers.getIpAddress().equals(host)) {
|
||||||
|
server = servers;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server == null) {
|
||||||
|
return "错误: 未找到服务器信息";
|
||||||
|
}
|
||||||
|
|
||||||
|
String username = server.getSshUsername();
|
||||||
|
String password = server.getSshPassword();
|
||||||
|
int port = server.getSshPort();
|
||||||
|
|
||||||
|
// 执行SSH命令
|
||||||
|
return executeSSHCommand(host, port, username, password, command);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return "SSH执行失败: " + e.getMessage();
|
||||||
|
}
|
||||||
|
} else if (tool.getName().equals("get_log")) {
|
||||||
|
try {
|
||||||
|
// 解析工具参数
|
||||||
|
Map<String, Object> args = gson.fromJson(tool.getArgs(), Map.class);
|
||||||
|
String host = args.get("host").toString();
|
||||||
|
String from = args.get("from").toString();
|
||||||
|
String to = args.get("to").toString();
|
||||||
|
// 使用正确的日期时间格式解析
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
LocalDateTime fromDate = LocalDateTime.parse(from, formatter);
|
||||||
|
LocalDateTime toDate = LocalDateTime.parse(to, formatter);
|
||||||
|
|
||||||
|
// 执行日志获取逻辑
|
||||||
|
List<Status> logList = statusDao.findByHost(host);
|
||||||
|
// 时间范围过滤
|
||||||
|
|
||||||
|
List<Status> filteredStatuses = logList.stream()
|
||||||
|
.filter(status -> status.getTimestamp() != null)
|
||||||
|
.filter(status -> !status.getTimestamp().isBefore(fromDate))
|
||||||
|
.filter(status -> !status.getTimestamp().isAfter(toDate))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<Status> optimizedStatuses = optimizeStatusData(filteredStatuses);
|
||||||
|
|
||||||
|
return gson.toJson(optimizedStatuses);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return "日志获取失败: " + e.getMessage();
|
||||||
|
}
|
||||||
|
} else if (tool.getName().equals("query_database")) {
|
||||||
|
Map<String, Object> args = gson.fromJson(tool.getArgs(), Map.class);
|
||||||
|
try {
|
||||||
|
QueryRequest queryRequest = new QueryRequest((String) args.get("query"));
|
||||||
|
QueryResponse q = milvusService.query(queryRequest);
|
||||||
|
long queryTime = System.currentTimeMillis();
|
||||||
|
StringBuilder sendMessage = new StringBuilder();
|
||||||
|
sendMessage.append("\nRAG数据库检索Answer:").append(q.getAnswer()).append("\nRAG数据库检索Context").append(q.getContext());
|
||||||
|
sendMessage.append("\nRAG数据库其他答案: ").append(gson.toJson(q.getRetrieved_docs())).append("\n\n\n");
|
||||||
|
return sendMessage.toString() + "\n";
|
||||||
|
}catch (Exception e) {}
|
||||||
|
} else if (tool.getName().equals("get_status")) {
|
||||||
|
try {
|
||||||
|
// 解析工具参数
|
||||||
|
Map<String, Object> args = gson.fromJson(tool.getArgs(), Map.class);
|
||||||
|
String host = args.get("host").toString();
|
||||||
|
Server server = null;
|
||||||
|
for (Server servers : serverDao.findAll()) {
|
||||||
|
if (servers.getIpAddress().equals(host)) {
|
||||||
|
server = servers;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status status = systemStatusService.getStatus(
|
||||||
|
server.getIpAddress(),
|
||||||
|
server.getSshUsername(),
|
||||||
|
server.getSshPassword()
|
||||||
|
);
|
||||||
|
return gson.toJson(status) + "\n" + gson.toJson(server);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return "SSH执行失败: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "未知工具: " + tool.getName();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 使用JSch执行SSH命令(支持后台执行)
|
||||||
|
*/
|
||||||
|
// 在类中添加一个静态Map来存储命令输出
|
||||||
|
private static Map<String, String> commandOutputMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用JSch执行SSH命令,将输出保存到Map中
|
||||||
|
*/
|
||||||
|
private String createSSHCommandWithOutReturn(String host, int port, String username, String password, String command) {
|
||||||
|
com.jcraft.jsch.JSch jsch = new com.jcraft.jsch.JSch();
|
||||||
|
com.jcraft.jsch.Session session = null;
|
||||||
|
com.jcraft.jsch.Channel channel = null;
|
||||||
|
|
||||||
|
// 生成唯一的命令ID用于Map键值
|
||||||
|
String commandId = host + "_" + System.currentTimeMillis();
|
||||||
|
|
||||||
|
System.out.println("开始执行SSH命令: " + commandId);
|
||||||
|
try {
|
||||||
|
// 创建会话
|
||||||
|
session = jsch.getSession(username, host, port);
|
||||||
|
session.setPassword(password);
|
||||||
|
|
||||||
|
// 跳过主机密钥检查
|
||||||
|
java.util.Properties config = new java.util.Properties();
|
||||||
|
config.put("StrictHostKeyChecking", "no");
|
||||||
|
session.setConfig(config);
|
||||||
|
|
||||||
|
// 连接
|
||||||
|
session.connect(5000); // 5秒超时
|
||||||
|
|
||||||
|
// 打开执行通道
|
||||||
|
channel = session.openChannel("exec");
|
||||||
|
|
||||||
|
// 修改命令以后台方式运行,并将输出重定向到指定文件
|
||||||
|
String backgroundCommand = command + " > /tmp/" + commandId + ".log 2>&1 & echo $!";
|
||||||
|
((com.jcraft.jsch.ChannelExec) channel).setCommand(backgroundCommand);
|
||||||
|
|
||||||
|
// 获取输出流
|
||||||
|
ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
|
||||||
|
channel.setOutputStream(responseStream);
|
||||||
|
|
||||||
|
// 连接通道
|
||||||
|
channel.connect();
|
||||||
|
|
||||||
|
// 等待短暂时间获取进程ID
|
||||||
|
Thread.sleep(100);
|
||||||
|
|
||||||
|
// 获取命令输出并存储到Map中
|
||||||
|
String result = responseStream.toString();
|
||||||
|
commandOutputMap.put(commandId, result);
|
||||||
|
|
||||||
|
// 立即返回,不等待命令完成
|
||||||
|
return "命令已启动后台执行,输出将保存到Map中,可通过ID '" + commandId + "' 获取";
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
String errorMsg = "SSH连接失败: " + e.getMessage();
|
||||||
|
commandOutputMap.put(commandId, errorMsg);
|
||||||
|
return errorMsg;
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (channel != null && channel.isConnected()) {
|
||||||
|
channel.disconnect();
|
||||||
|
}
|
||||||
|
if (session != null && session.isConnected()) {
|
||||||
|
session.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供获取命令输出的方法
|
||||||
|
public static String getCommandOutput(String commandId) {
|
||||||
|
return commandOutputMap.getOrDefault(commandId, "未找到指定命令的输出");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供获取所有命令输出的方法
|
||||||
|
public static Map<String, String> getAllCommandOutputs() {
|
||||||
|
return new HashMap<>(commandOutputMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String executeSSHCommand(String host, int port, String username, String password, String command) {
|
||||||
|
com.jcraft.jsch.JSch jsch = new com.jcraft.jsch.JSch();
|
||||||
|
com.jcraft.jsch.Session session = null;
|
||||||
|
com.jcraft.jsch.Channel channel = null;
|
||||||
|
|
||||||
|
System.out.println("开始执行SSH命令: " + command);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建会话
|
||||||
|
session = jsch.getSession(username, host, port);
|
||||||
|
session.setPassword(password);
|
||||||
|
|
||||||
|
// 设置SSH配置选项
|
||||||
|
java.util.Properties config = new java.util.Properties();
|
||||||
|
config.put("StrictHostKeyChecking", "no");
|
||||||
|
config.put("PreferredAuthentications", "password");
|
||||||
|
config.put("ServerAliveInterval", "60");
|
||||||
|
session.setConfig(config);
|
||||||
|
|
||||||
|
// 连接(增加超时时间)
|
||||||
|
session.connect(10000); // 10秒超时
|
||||||
|
|
||||||
|
// 打开Shell通道
|
||||||
|
ChannelShell channelShell = (ChannelShell) session.openChannel("shell");
|
||||||
|
channelShell.setPty(true);
|
||||||
|
channelShell.setPtyType("xterm-256color");
|
||||||
|
channelShell.setPtySize(80, 24, 0, 0);
|
||||||
|
|
||||||
|
// 设置输入输出流
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
ByteArrayOutputStream errorStream = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
// 使用sudo -S从标准输入读取密码
|
||||||
|
String rootCommand = "echo '" + password + "' | sudo -S " + command + "\nexit\n";
|
||||||
|
channelShell.setInputStream(new ByteArrayInputStream(rootCommand.getBytes()));
|
||||||
|
channelShell.setOutputStream(outputStream);
|
||||||
|
channelShell.setExtOutputStream(errorStream);
|
||||||
|
|
||||||
|
// 连接通道
|
||||||
|
channelShell.connect();
|
||||||
|
|
||||||
|
// 等待命令执行完成
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
long maxWaitTime = 30000;
|
||||||
|
|
||||||
|
while (!channelShell.isClosed() && (System.currentTimeMillis() - startTime) < maxWaitTime) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(100);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取执行结果
|
||||||
|
String output = outputStream.toString("UTF-8");
|
||||||
|
String error = errorStream.toString("UTF-8");
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
if (!output.isEmpty()) {
|
||||||
|
result.append(output);
|
||||||
|
}
|
||||||
|
if (!error.isEmpty()) {
|
||||||
|
result.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("命令执行完成,输出长度: " + result.length());
|
||||||
|
return result.length() > 0 ? result.toString() : "命令执行完成(无输出)";
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("SSH执行异常: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
return "SSH连接失败: " + e.getMessage();
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
if (channel != null && channel.isConnected()) {
|
||||||
|
channel.disconnect();
|
||||||
|
}
|
||||||
|
if (session != null && session.isConnected()) {
|
||||||
|
session.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否需要使用工具
|
||||||
|
*/
|
||||||
|
public ToolDecision shouldUseTools(String userMessage,List<ChatMessage> context) {
|
||||||
|
// 构建工具判断请求
|
||||||
|
List<Server> serverList = serverDao.findAll();
|
||||||
|
serverMap.clear();
|
||||||
|
for (Server server : serverList) {
|
||||||
|
serverMap.put(server.getName(), server.getIpAddress());
|
||||||
|
}
|
||||||
|
System.out.println("目前可用服务器:" + gson.toJson(serverMap));
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
//requestBody.put("model", MODEL_LITE);
|
||||||
|
requestBody.put("model", MODEL_NAME);
|
||||||
|
requestBody.put("keep_alive", "60m");
|
||||||
|
requestBody.put("stream", false); // 非流式响应
|
||||||
|
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
|
||||||
|
// 添加系统提示消息
|
||||||
|
Map<String, Object> systemMessage = new HashMap<>();
|
||||||
|
String time = dateFormat.format(new Date());
|
||||||
|
systemMessage.put("role", "system");
|
||||||
|
systemMessage.put("content", "你是一个工具判断助手。根据用户的请求,判断是否需要调用工具来获取额外信息。\n" +
|
||||||
|
"目前时间是: " + time+ "\n" +
|
||||||
|
"内网服务器列表是(内网地址): " + gson.toJson(serverMap) + "\n" +
|
||||||
|
"如果需要,请严格按照以下JSON格式回复:\n" +
|
||||||
|
"{\"shouldUseTool\": true, \"toolArgs\": [\n" +
|
||||||
|
" {\"name\": \"get_log\", \"args\": \"{\"from\":\"格式化时间起点\",\"to\":\"格式化时间终点\",\"host\":\"服务器ip\" }\" , \"risk\": 数字},\n" +
|
||||||
|
" {\"name\": \"get_status\", \"args\": \"{\"host\":\"服务器ip\"}\" , \"risk\": 数字},\n" +
|
||||||
|
" {\"name\": \"run_ssh\", \"args\": \"{\"ssh\": \"ssh指令\", \"host\":\"服务器ip\"}\" , \"risk\": 数字},\n" +
|
||||||
|
// " {\"name\": \"run_service\", \"args\": \"{\"ssh\": \"ssh指令\", \"host\":\"服务器ip\"}\"},\n" +
|
||||||
|
// " {\"name\": \"query_database\", \"args\": \"{\"query\": \"关键词\"},\n" +
|
||||||
|
"]}\n" +
|
||||||
|
"如果不需要,请回复:{\"shouldUseTool\": false}\n\n" +
|
||||||
|
"工具详细说明:\n" +
|
||||||
|
"get_log - 获取指定时间内的日志\n" +
|
||||||
|
" 使用场景:任何需要分析日志的情况,要求明确获取历史/日志的时候调用,否则优先使用run_ssh\n" +
|
||||||
|
" 参数要求:需要提供 from、to 和 host 参数,格式为 yyyy-MM-dd HH:mm:ss\n" +
|
||||||
|
" 示例:{\"from\":\"2024-12-03 20:32:32\",\"to\":\"2025-01-03 20:32:32\",\"host\":\"服务器ip\"}\n\n" +
|
||||||
|
"get_status - 获取服务器运行情况\n" +
|
||||||
|
" 使用场景:任何需要查看服务器运行情况的场景\n" +
|
||||||
|
" 参数要求:需要提供 host\n" +
|
||||||
|
" 示例:{\"host\":\"服务器ip\"}\n" +
|
||||||
|
"run_ssh - 运行ssh指令并返回结果\n" +
|
||||||
|
" 使用场景: 你觉得需要调用指定ssh指令解决问题的时候\n" +
|
||||||
|
" 参数要求:需要提供 ssh、host\n" +
|
||||||
|
" 示例:{\"ssh\": \"ssh指令\", \"host\":\"服务器ip\"}\n" +
|
||||||
|
// "run_service - 后台运行指定ssh指令并异步调用\n" +
|
||||||
|
// " 使用场景: 你觉得需要后台运行指定ssh指令的时候,此操作无需阻塞等待返回,而是类似于启动某个服务,无视参数返回\n" +
|
||||||
|
// " 参数要求:需要提供 ssh、host\n" +
|
||||||
|
// " 示例:{\"ssh\": \"ssh指令\", \"host\":\"服务器ip\"}\n\n" +
|
||||||
|
// "3. query_database - 从知识库查询需要运维等知识数据\n" +
|
||||||
|
// " 使用场景:需要获取知识库的场景,注意只在运维知识库的时候调用,娱乐和正常聊天无需使用\n" +
|
||||||
|
// " 参数要求:需要提供 query 参数\n" +
|
||||||
|
// " 示例:{\"query\": \"关键词\"}\n\n" +
|
||||||
|
"判断原则:\n" +
|
||||||
|
"- 每个终端ssh都必须要设置risk,(0-100)越大风险越高\n" +
|
||||||
|
"- 只有当用户明确需要某项具体信息时才调用工具\n" +
|
||||||
|
"- 工具参数必须准确提取,不能随意猜测\n" +
|
||||||
|
"- 允许多次使用工具,尤其是ssh工具,你可以多次使用,来实现获取数据+执行\n"+
|
||||||
|
"- 严格按照指定JSON格式回复,不要添加任何其他内容\n" +
|
||||||
|
"- 如果不需要工具,直接返回shouldUseTool为false\n\n" +
|
||||||
|
"示例:\n" +
|
||||||
|
"用户:\"查看服务器100.89.166.61的日志\"\n" +
|
||||||
|
"回复:{\"shouldUseTool\": true, \"toolArgs\": [{\"name\": \"get_log\", \"args\": \"{\\\"from\\\":\\\"2024-12-03 19:32:32\\\",\\\"to\\\":\\\"2024-12-03 20:32:32\\\",\\\"host\\\":\\\"100.89.166.61\\\"}\", \"risk\": 20}]}\n"+
|
||||||
|
"用户: \"检查服务器100.89.166.61的Java版本\"\n" +
|
||||||
|
"回复:{\"shouldUseTool\": true, \"toolArgs\": [{\"name\": \"run_ssh\", \"args\": \"{\\\"ssh\\\":\\\"java -version\\\",\\\"host\\\":\\\"100.89.166.61\\\"}\", \"risk\": 0}]}\n\n"
|
||||||
|
);
|
||||||
|
messages.add(systemMessage);
|
||||||
|
for (int i = context.size()-1; i >= Math.max(0, context.size() - 10); i--) {
|
||||||
|
ChatMessage message = context.get(i);
|
||||||
|
Map<String, Object> chatMessage = new HashMap<>();
|
||||||
|
chatMessage.put("role", message.getRole());
|
||||||
|
chatMessage.put("content", message.getContent());
|
||||||
|
messages.add(chatMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加当前用户消息
|
||||||
|
Map<String, Object> currentMessage = new HashMap<>();
|
||||||
|
currentMessage.put("role", "user");
|
||||||
|
currentMessage.put("content", "用户请求: " + userMessage + "\n请判断是否需要调用工具,请尽量调用工具来提供更好的服务体验,返回不要添加md内容而是直接给纯JSON即可,无需使用```符号");
|
||||||
|
messages.add(currentMessage);
|
||||||
|
// List<ChatMessage> historyMessages = historyService.getHistoryForContext(sessionId);
|
||||||
|
// int historyCount = Math.min(3, historyMessages.size());
|
||||||
|
// for (int i = historyMessages.size() - historyCount; i < historyMessages.size(); i++) {
|
||||||
|
// ChatMessage msg = historyMessages.get(i);
|
||||||
|
// Map<String, Object> historyMsg = new HashMap<>();
|
||||||
|
// historyMsg.put("role", msg.getRole());
|
||||||
|
// historyMsg.put("content", msg.getContent());
|
||||||
|
// messages.add(historyMsg);
|
||||||
|
// }
|
||||||
|
requestBody.put("messages", messages);
|
||||||
|
//System.out.println(gson.toJson(requestBody));
|
||||||
|
// 发送请求
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(OLLAMA_URL)
|
||||||
|
.post(RequestBody.create(gson.toJson(requestBody), MediaType.parse("application/json")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
|
String responseBody = response.body().string();
|
||||||
|
|
||||||
|
Map<String, Object> responseMap = gson.fromJson(responseBody, Map.class);
|
||||||
|
Map<String, Object> messageMap = (Map<String, Object>) responseMap.get("message");
|
||||||
|
String content = (String) messageMap.get("content");
|
||||||
|
System.out.println("AI:"+content);
|
||||||
|
|
||||||
|
content = content.replace("```json", "").replace("```", "").replaceAll("\n","");
|
||||||
|
|
||||||
|
// 解析AI返回的JSON
|
||||||
|
try {
|
||||||
|
return gson.fromJson(content, ToolDecision.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 如果解析失败,则尝试进行模糊转化
|
||||||
|
try {
|
||||||
|
// 使用正则表达式提取 JSON 对象或数组
|
||||||
|
String regex = "\\{(?:[^{}]|(?))*\\}|\\[(?:[^\\[\\]]|(?))*\\]";
|
||||||
|
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex);
|
||||||
|
java.util.regex.Matcher matcher = pattern.matcher(content);
|
||||||
|
if (matcher.find()) {
|
||||||
|
String jsonCandidate = matcher.group();
|
||||||
|
return gson.fromJson(jsonCandidate, ToolDecision.class);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolDecision(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return new ToolDecision(false);
|
||||||
|
// 请求失败,默认不使用工具
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolDecision(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Status> optimizeStatusData(List<Status> statuses) {
|
||||||
|
if (statuses.size() <= 2) {
|
||||||
|
return statuses; // 数据点太少无需优化
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Status> result = new ArrayList<>();
|
||||||
|
result.add(statuses.get(0)); // 始终保留第一个点
|
||||||
|
|
||||||
|
// 计算CPU和内存使用率变化的阈值(基于整体数据计算)
|
||||||
|
double cpuThreshold = calculateThreshold(statuses, Status::getCpuUsagePercent);
|
||||||
|
double memoryThreshold = calculateThreshold(statuses, Status::getMemoryUsagePercent);
|
||||||
|
|
||||||
|
// 设置最小阈值,避免过度优化
|
||||||
|
cpuThreshold = Math.max(cpuThreshold, 0.5); // 最小0.5%
|
||||||
|
memoryThreshold = Math.max(memoryThreshold, 0.5); // 最小0.5%
|
||||||
|
|
||||||
|
Status previousStatus = statuses.get(0);
|
||||||
|
|
||||||
|
for (int i = 1; i < statuses.size() - 1; i++) {
|
||||||
|
Status current = statuses.get(i);
|
||||||
|
|
||||||
|
// 检查CPU或内存使用率是否有显著变化
|
||||||
|
boolean significantChange =
|
||||||
|
hasSignificantChange(previousStatus, current, cpuThreshold, memoryThreshold);
|
||||||
|
|
||||||
|
if (significantChange) {
|
||||||
|
result.add(current);
|
||||||
|
previousStatus = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 始终保留最后一个点
|
||||||
|
if (!statuses.isEmpty() && !result.contains(statuses.get(statuses.size() - 1))) {
|
||||||
|
result.add(statuses.get(statuses.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasSignificantChange(Status prev, Status curr,
|
||||||
|
double cpuThreshold, double memoryThreshold) {
|
||||||
|
Double prevCpu = prev.getCpuUsagePercent();
|
||||||
|
Double currCpu = curr.getCpuUsagePercent();
|
||||||
|
Double prevMem = prev.getMemoryUsagePercent();
|
||||||
|
Double currMem = curr.getMemoryUsagePercent();
|
||||||
|
|
||||||
|
// 如果任一值为空,则认为有变化
|
||||||
|
if (prevCpu == null || currCpu == null || prevMem == null || currMem == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查变化是否超过阈值
|
||||||
|
return Math.abs(currCpu - prevCpu) >= cpuThreshold ||
|
||||||
|
Math.abs(currMem - prevMem) >= memoryThreshold;
|
||||||
|
}
|
||||||
|
private double calculateThreshold(List<Status> statuses, java.util.function.Function<Status, Double> getter) {
|
||||||
|
// 收集所有有效数值
|
||||||
|
List<Double> values = statuses.stream()
|
||||||
|
.map(getter)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (values.size() < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算相邻数值间的差值
|
||||||
|
List<Double> differences = new ArrayList<>();
|
||||||
|
for (int i = 1; i < values.size(); i++) {
|
||||||
|
differences.add(Math.abs(values.get(i) - values.get(i-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算平均差值作为阈值基础
|
||||||
|
double averageDifference = differences.stream()
|
||||||
|
.mapToDouble(Double::doubleValue)
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
|
||||||
|
// 返回平均差值的一半作为阈值,这样可以过滤掉较小的变化
|
||||||
|
return averageDifference / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearSession(String sessionId) {
|
||||||
|
chatHistoryService.clearHistory(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReningService {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.jcraft.jsch.*;
|
||||||
|
import org.springframework.web.socket.*;
|
||||||
|
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class SSHWebSocketHandler extends AbstractWebSocketHandler {
|
||||||
|
|
||||||
|
// 存储WebSocket会话与SSH连接的映射
|
||||||
|
private final Map<WebSocketSession, SSHConnection> connections = new ConcurrentHashMap<>();
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
// 连接建立时不做处理,等待客户端发送连接信息
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 首次连接:处理连接信息
|
||||||
|
if (!connections.containsKey(session)) {
|
||||||
|
Connect connect = gson.fromJson(payload, Connect.class);
|
||||||
|
handleConnectionMessage(session, connect);
|
||||||
|
} else {
|
||||||
|
// 已连接:优先处理resize请求,否则视为命令
|
||||||
|
try {
|
||||||
|
Resize resize = gson.fromJson(payload, Resize.class);
|
||||||
|
if (resize != null && "resize".equals(resize.type)) {
|
||||||
|
SSHConnection connection = connections.get(session);
|
||||||
|
if (connection != null) {
|
||||||
|
connection.resizePty(resize.cols, resize.rows);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 不是resize请求,继续处理为命令
|
||||||
|
}
|
||||||
|
handleSSHCommand(session, payload);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (connections.containsKey(session)) {
|
||||||
|
handleSSHCommand(session, payload);
|
||||||
|
} else {
|
||||||
|
session.sendMessage(new TextMessage("ERROR:Invalid message format"));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
|
||||||
|
SSHConnection connection = connections.get(session);
|
||||||
|
if (connection != null) {
|
||||||
|
try {
|
||||||
|
connection.getOutputStream().write(message.getPayload().array());
|
||||||
|
connection.getOutputStream().flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
session.sendMessage(new TextMessage("ERROR:" + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理SSH连接请求
|
||||||
|
*/
|
||||||
|
private void handleConnectionMessage(WebSocketSession session, Connect connect) {
|
||||||
|
try {
|
||||||
|
// 解析连接参数(使用默认值兜底)
|
||||||
|
String host = connect.host;
|
||||||
|
int port = connect.port > 0 ? connect.port : 22;
|
||||||
|
String username = connect.username;
|
||||||
|
String password = connect.password != null ? connect.password : "";
|
||||||
|
String term = connect.term != null ? connect.term : "xterm-256color";
|
||||||
|
int cols = connect.cols > 0 ? connect.cols : 80;
|
||||||
|
int rows = connect.rows > 0 ? connect.rows : 24;
|
||||||
|
|
||||||
|
// 建立SSH连接
|
||||||
|
JSch jsch = new JSch();
|
||||||
|
Session sshSession = jsch.getSession(username, host, port);
|
||||||
|
sshSession.setPassword(password);
|
||||||
|
sshSession.setConfig("StrictHostKeyChecking", "no");
|
||||||
|
// 增加超时配置和保持连接
|
||||||
|
sshSession.setConfig("ServerAliveInterval", "60000"); // 1分钟发送一次保活包
|
||||||
|
sshSession.connect(30000); // 30秒连接超时
|
||||||
|
|
||||||
|
// 配置Shell通道(关键:启用PTY和颜色支持)
|
||||||
|
ChannelShell channel = (ChannelShell) sshSession.openChannel("shell");
|
||||||
|
channel.setPty(true); // 启用伪终端(必须)
|
||||||
|
channel.setPtyType(term); // 设置终端类型(支持256色)
|
||||||
|
channel.setPtySize(cols, rows, 0, 0); // 设置终端尺寸
|
||||||
|
|
||||||
|
channel.connect(10000); // 10秒通道连接超时
|
||||||
|
|
||||||
|
// 保存连接信息
|
||||||
|
SSHConnection connection = new SSHConnection(sshSession, channel, cols, rows);
|
||||||
|
connections.put(session, connection);
|
||||||
|
|
||||||
|
// 启动输出读取线程
|
||||||
|
startSSHOutputReader(session, connection);
|
||||||
|
|
||||||
|
// 通知客户端连接成功
|
||||||
|
session.sendMessage(new TextMessage("CONNECTED"));
|
||||||
|
} catch (JSchException e) {
|
||||||
|
String errorMsg = "SSH连接失败: " + (e.getMessage() != null ? e.getMessage() : "未知错误");
|
||||||
|
sendError(session, errorMsg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendError(session, "处理连接时出错: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理SSH命令发送
|
||||||
|
*/
|
||||||
|
private void handleSSHCommand(WebSocketSession session, String command) {
|
||||||
|
SSHConnection connection = connections.get(session);
|
||||||
|
if (connection != null) {
|
||||||
|
try {
|
||||||
|
connection.getOutputStream().write(command.getBytes());
|
||||||
|
connection.getOutputStream().flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
sendError(session, "发送命令失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动线程读取SSH输出并转发到前端
|
||||||
|
*/
|
||||||
|
private void startSSHOutputReader(WebSocketSession session, SSHConnection connection) {
|
||||||
|
Thread readerThread = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
InputStream inputStream = connection.getInputStream();
|
||||||
|
byte[] buffer = new byte[8192]; // 增大缓冲区,减少分包
|
||||||
|
int bytesRead;
|
||||||
|
|
||||||
|
// 持续读取直到连接关闭
|
||||||
|
while (connection.getSession().isConnected() &&
|
||||||
|
connection.getChannel().isConnected() &&
|
||||||
|
(bytesRead = inputStream.read(buffer)) != -1) {
|
||||||
|
|
||||||
|
// 用二进制消息发送原始数据(保留ANSI颜色序列)
|
||||||
|
session.sendMessage(new BinaryMessage(buffer, 0, bytesRead, true));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
sendError(session, "读取SSH输出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
sendError(session, "输出处理异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 输出流结束时关闭连接
|
||||||
|
try {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
session.close(CloseStatus.SERVER_ERROR);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 忽略关闭异常
|
||||||
|
}
|
||||||
|
connections.remove(session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
readerThread.setDaemon(true); // 设为守护线程,随主线程退出
|
||||||
|
readerThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送错误消息到前端
|
||||||
|
*/
|
||||||
|
private void sendError(WebSocketSession session, String message) {
|
||||||
|
try {
|
||||||
|
if (session.isOpen()) {
|
||||||
|
session.sendMessage(new TextMessage("ERROR:" + message));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
|
// 关闭连接时清理资源
|
||||||
|
SSHConnection connection = connections.remove(session);
|
||||||
|
if (connection != null) {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||||
|
// 处理传输错误
|
||||||
|
exception.printStackTrace();
|
||||||
|
connections.remove(session);
|
||||||
|
session.close(CloseStatus.SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH连接信息封装类
|
||||||
|
*/
|
||||||
|
private static class SSHConnection {
|
||||||
|
private final Session session;
|
||||||
|
private final ChannelShell channel;
|
||||||
|
private final InputStream inputStream;
|
||||||
|
private final OutputStream outputStream;
|
||||||
|
private int cols; // 当前终端列数
|
||||||
|
private int rows; // 当前终端行数
|
||||||
|
|
||||||
|
public SSHConnection(Session session, ChannelShell channel, int cols, int rows) throws IOException {
|
||||||
|
this.session = session;
|
||||||
|
this.channel = channel;
|
||||||
|
this.inputStream = channel.getInputStream();
|
||||||
|
this.outputStream = channel.getOutputStream();
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新终端尺寸
|
||||||
|
public void resizePty(int newCols, int newRows) {
|
||||||
|
if (channel.isConnected() && (newCols != this.cols || newRows != this.rows)) {
|
||||||
|
this.cols = newCols;
|
||||||
|
this.rows = newRows;
|
||||||
|
channel.setPtySize(newCols, newRows, 0, 0); // 应用新尺寸
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getter方法
|
||||||
|
public Session getSession() { return session; }
|
||||||
|
public ChannelShell getChannel() { return channel; }
|
||||||
|
public InputStream getInputStream() { return inputStream; }
|
||||||
|
public OutputStream getOutputStream() { return outputStream; }
|
||||||
|
|
||||||
|
// 关闭连接
|
||||||
|
public void close() {
|
||||||
|
if (channel != null && channel.isConnected()) {
|
||||||
|
channel.disconnect();
|
||||||
|
}
|
||||||
|
if (session != null && session.isConnected()) {
|
||||||
|
session.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端连接参数实体类
|
||||||
|
*/
|
||||||
|
static class Connect {
|
||||||
|
String host;
|
||||||
|
int port;
|
||||||
|
String username;
|
||||||
|
String password;
|
||||||
|
String term; // 终端类型(如xterm-256color)
|
||||||
|
int cols; // 终端列数
|
||||||
|
int rows; // 终端行数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端终端尺寸调整请求实体类
|
||||||
|
*/
|
||||||
|
static class Resize {
|
||||||
|
String type; // 固定为"resize"
|
||||||
|
int cols; // 新列数
|
||||||
|
int rows; // 新行数
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.ast.reisaadminspring.service;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(new SSHWebSocketHandler(), "/ssh")
|
||||||
|
.setAllowedOrigins("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
spring.application.name=reisaAdminSpring
|
|
||||||
spring.data.mongodb.uri=mongodb://reisaAdmin:nbAC8hi8xdJeBDDT@127.0.0.1:27017/reisaadmin
|
|
||||||
server.port=48102
|
|
||||||
|
|
||||||
spring.data.redis.host=127.0.0.1
|
|
||||||
spring.data.redis.port: 6379
|
|
||||||
@@ -3,4 +3,13 @@ spring.data.mongodb.uri=mongodb://reisaAdmin:nbAC8hi8xdJeBDDT@100.80.156.98:2701
|
|||||||
server.port=48102
|
server.port=48102
|
||||||
|
|
||||||
spring.data.redis.host=127.0.0.1
|
spring.data.redis.host=127.0.0.1
|
||||||
spring.data.redis.port: 6379
|
spring.data.redis.port: 6379
|
||||||
|
|
||||||
|
spring.mail.host=smtp.163.com
|
||||||
|
spring.mail.port=465
|
||||||
|
spring.mail.username=astralpath@163.com
|
||||||
|
spring.mail.password=ZXALAQONILLRIIRZ
|
||||||
|
spring.mail.default-encoding=UTF-8
|
||||||
|
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
|
||||||
|
spring.mail.properties.mail.debug=false
|
||||||
|
server.servlet.session.timeout=3m
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user