feat(mobile): ✨ added photo album feature on mobile; added blank AI assistant page

This commit is contained in:
卡仔
2025-10-31 08:09:01 +08:00
committed by Dawn
parent 660728333c
commit 38eb9e5d23
9 changed files with 426 additions and 15 deletions
+65 -2
View File
@@ -36,14 +36,15 @@ import { useMitt } from '@/hooks/useMitt.ts'
import rustWebSocketClient from '@/services/webSocketRust'
import { useContactStore } from '@/stores/contacts.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { isWindows } from '@/utils/PlatformConstants'
import { MittEnum, NotificationTypeEnum, TauriCommand } from '@/enums'
import { isMobile, isWindows } from '@/utils/PlatformConstants'
import { MittEnum, MsgEnum, NotificationTypeEnum, TauriCommand } from '@/enums'
import { clearListener, initListener, readCountQueue } from '@/utils/ReadCountQueue'
import { emitTo, listen } from '@tauri-apps/api/event'
import { UserAttentionType } from '@tauri-apps/api/window'
import type { MessageType } from '@/services/types.ts'
import { WsResponseMessageType } from '@/services/wsType.ts'
import { useChatStore } from '@/stores/chat'
import { useFileStore } from '@/stores/file'
import { useUserStore } from '@/stores/user'
import { useSettingStore } from '@/stores/setting.ts'
import { invokeSilently } from '@/utils/TauriInvokeHandler'
@@ -53,6 +54,7 @@ import { audioManager } from '@/utils/AudioManager'
const route = useRoute()
const userStore = useUserStore()
const chatStore = useChatStore()
const fileStore = useFileStore()
const settingStore = useSettingStore()
const userUid = computed(() => userStore.userInfo!.uid)
const appWindow = WebviewWindow.getCurrent()
@@ -169,6 +171,62 @@ const playMessageSound = async () => {
}
}
/**
* 从消息中提取文件信息并添加到 file store
*/
const addFileToStore = (data: MessageType) => {
const { message } = data
const { type, body, roomId, id } = message
// 只处理图片和视频类型
if (type !== MsgEnum.IMAGE && type !== MsgEnum.VIDEO) {
return
}
// 提取文件信息
const fileUrl = body.url
if (!fileUrl) {
return
}
// 从 URL 中提取文件名
let fileName = ''
try {
const urlObj = new URL(fileUrl)
const pathname = urlObj.pathname
fileName = pathname.substring(pathname.lastIndexOf('/') + 1)
} catch (e) {
// 如果不是有效的 URL,直接使用消息 ID 作为文件名
fileName = `${id}.${type === MsgEnum.IMAGE ? 'jpg' : 'mp4'}`
}
// 从文件名中提取后缀
const suffix = fileName.includes('.')
? fileName.substring(fileName.lastIndexOf('.') + 1)
: type === MsgEnum.IMAGE
? 'jpg'
: 'mp4'
// 确定 MIME 类型
let mimeType = ''
if (type === MsgEnum.IMAGE) {
mimeType = `image/${suffix === 'jpg' ? 'jpeg' : suffix}`
} else if (type === MsgEnum.VIDEO) {
mimeType = `video/${suffix}`
}
// 添加到 file store
fileStore.addFile({
id,
roomId,
fileName,
type: type === MsgEnum.IMAGE ? 'image' : 'video',
url: fileUrl,
suffix,
mimeType
})
}
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
if (chatStore.checkMsgExist(data.message.roomId, data.message.id)) {
return
@@ -184,6 +242,11 @@ useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
data
})
// 如果是图片或视频消息,添加到 file store(仅移动端需要)
if (isMobile()) {
addFileToStore(data)
}
// 不是自己发的消息才通知
if (data.fromUser.uid !== userUid.value) {
// 获取该消息的会话信息
+6 -1
View File
@@ -144,16 +144,21 @@ const handleSave = async () => {
// 保存文件信息到 file store
const roomId = getCurrentRoomId()
if (roomId) {
// 获取文件状态,使用相对路径(localPath)而不是绝对路径
const fileStatus = fileDownloadStore.getFileStatus(imageUrl)
const localPath = fileStatus.localPath || result
// 如果没有消息信息,手动创建文件信息
const fileInfo = {
id: props.message!.id, // 生成唯一ID
roomId,
fileName,
type: 'image' as const,
url: result,
url: localPath, // 使用相对路径
suffix: fileName.split('.').pop()?.toLowerCase()
}
fileStore.addFile(fileInfo)
console.log('🔍 [ImagePreview Debug] 保存文件信息到 fileStore:', fileInfo)
}
}
} catch (e) {
+62 -1
View File
@@ -22,11 +22,12 @@
<script setup lang="ts">
import { emitTo } from '@tauri-apps/api/event'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { NotificationTypeEnum, TauriCommand } from '@/enums'
import { MsgEnum, NotificationTypeEnum, TauriCommand } from '@/enums'
import { useMitt } from '@/hooks/useMitt'
import type { MessageType } from '@/services/types'
import { WsResponseMessageType } from '@/services/wsType'
import { useChatStore } from '@/stores/chat'
import { useFileStore } from '@/stores/file'
import { useGlobalStore } from '@/stores/global'
import { useSettingStore } from '@/stores/setting'
import { useUserStore } from '@/stores/user'
@@ -50,6 +51,7 @@ interface MobileLayoutProps {
const route = useRoute()
const chatStore = useChatStore()
const fileStore = useFileStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const settingStore = useSettingStore()
@@ -92,6 +94,62 @@ const backgroundImageStyle = computed(() => {
return styles
})
/**
* 从消息中提取文件信息并添加到 file store
*/
const addFileToStore = (data: MessageType) => {
const { message } = data
const { type, body, roomId, id } = message
// 只处理图片和视频类型
if (type !== MsgEnum.IMAGE && type !== MsgEnum.VIDEO) {
return
}
// 提取文件信息
const fileUrl = body.url
if (!fileUrl) {
return
}
// 从 URL 中提取文件名
let fileName = ''
try {
const urlObj = new URL(fileUrl)
const pathname = urlObj.pathname
fileName = pathname.substring(pathname.lastIndexOf('/') + 1)
} catch (e) {
// 如果不是有效的 URL,直接使用消息 ID 作为文件名
fileName = `${id}.${type === MsgEnum.IMAGE ? 'jpg' : 'mp4'}`
}
// 从文件名中提取后缀
const suffix = fileName.includes('.')
? fileName.substring(fileName.lastIndexOf('.') + 1)
: type === MsgEnum.IMAGE
? 'jpg'
: 'mp4'
// 确定 MIME 类型
let mimeType = ''
if (type === MsgEnum.IMAGE) {
mimeType = `image/${suffix === 'jpg' ? 'jpeg' : suffix}`
} else if (type === MsgEnum.VIDEO) {
mimeType = `video/${suffix}`
}
// 添加到 file store
fileStore.addFile({
id,
roomId,
fileName,
type: type === MsgEnum.IMAGE ? 'image' : 'video',
url: fileUrl,
suffix,
mimeType
})
}
/** 处理收到的消息 */
useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
if (chatStore.checkMsgExist(data.message.roomId, data.message.id)) {
@@ -106,6 +164,9 @@ useMitt.on(WsResponseMessageType.RECEIVE_MESSAGE, async (data: MessageType) => {
await invokeSilently(TauriCommand.SAVE_MSG, {
data
})
// 如果是图片或视频消息,添加到 file store
addFileToStore(data)
if (data.fromUser.uid !== userUid.value) {
// 获取该消息的会话信息
const session = chatStore.sessionList.find((s) => s.roomId === data.message.roomId)
+2 -1
View File
@@ -82,7 +82,8 @@ const imageList = ref<
const getImageList = async () => {
const data = await fileStore.getRoomFilesForDisplay(globalStore.currentSessionRoomId)
imageList.value = data.filter((item) => item.type === 'image').map((item) => ({ url: item.displayUrl }))
const filteredImages = data.filter((item) => item.type === 'image')
imageList.value = filteredImages.map((item) => ({ url: item.displayUrl }))
}
onMounted(() => {
+26
View File
@@ -0,0 +1,26 @@
<template>
<AutoFixHeightPage :show-footer="false">
<template #header>
<HeaderBar
:isOfficial="false"
class="bg-white"
style="border-bottom: 1px solid; border-color: #dfdfdf"
:hidden-right="true"
room-name="AI助手" />
</template>
<template #container>
<div class="bg-cover bg-center flex flex-col overflow-auto h-full">
<div class="flex flex-col flex-1 gap-20px py-15px px-20px">
<!-- TODO: 这里后续添加 AI 助手的内容 -->
</div>
</div>
</template>
</AutoFixHeightPage>
</template>
<script setup lang="ts">
// AI 助手页面
</script>
<style lang="scss" scoped></style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div>
<AutoFixHeightPage :show-footer="false">
<template #header>
<HeaderBar
:isOfficial="false"
class="bg-white"
style="border-bottom: 1px solid; border-color: #dfdfdf"
:hidden-right="true"
room-name="我的相册" />
</template>
<template #container>
<div class="flex flex-col bg-#fefefe overflow-auto h-full">
<div class="flex flex-col p-20px gap-20px">
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center items-center py-40px">
<n-spin size="medium" />
</div>
<!-- 空状态 -->
<div v-else-if="allImages.length === 0" class="flex flex-col justify-center items-center py-40px gap-10px">
<svg class="iconpark-icon w-60px h-60px text-gray">
<use href="#xiangce"></use>
</svg>
<div class="text-gray text-14px">暂无图片</div>
</div>
<!-- 图片网格 -->
<div v-else class="grid grid-cols-4 gap-1">
<div
v-for="(image, index) in allImages"
:key="index"
class="overflow-hidden bg-gray-100 aspect-square cursor-pointer"
@click="handleImageClick(image)">
<img :src="image.displayUrl" class="w-full h-full object-cover" alt="相册图片" />
</div>
</div>
</div>
</div>
</template>
</AutoFixHeightPage>
<!-- 图片预览组件 -->
<ImagePreview
v-model:visible="showImagePreviewRef"
:image-url="activeImageUrl"
:show-forward="false"
:show-save="false"
:show-more="false" />
</div>
</template>
<script setup lang="ts">
import { useFileStore } from '@/stores/file'
import { useGlobalStore } from '@/stores/global'
import ImagePreview from '@/mobile/components/ImagePreview.vue'
const fileStore = useFileStore()
const globalStore = useGlobalStore()
// 图片预览状态
const showImagePreviewRef = ref(false)
const activeImageUrl = ref('')
const loading = ref(true)
// 所有图片列表
const allImages = ref<Array<{ displayUrl: string; originalUrl: string; id: string; roomId: string }>>([])
/**
* 获取所有房间的图片
*/
const getAllImages = async () => {
loading.value = true
try {
// 检查 roomFilesMap 是否为空,如果为空则扫描本地文件
if (Object.keys(fileStore.roomFilesMap).length === 0) {
if (globalStore.currentSessionRoomId) {
console.log('🔍 [MyAlbum Debug] 扫描本地文件,roomId:', globalStore.currentSessionRoomId)
await fileStore.scanLocalFiles(globalStore.currentSessionRoomId)
}
}
const roomFilesMap = fileStore.roomFilesMap
console.log('🔍 [MyAlbum Debug] roomFilesMap:', roomFilesMap)
const imagesList: Array<{ displayUrl: string; originalUrl: string; id: string; roomId: string }> = []
// 遍历所有房间
for (const roomId in roomFilesMap) {
const files = await fileStore.getRoomFilesForDisplay(roomId)
console.log('🔍 [MyAlbum Debug] roomId:', roomId, 'files:', files)
// 只获取图片类型的文件
const images = files.filter((file) => file.type === 'image')
console.log('🔍 [MyAlbum Debug] roomId:', roomId, 'images:', images)
imagesList.push(
...images.map((img) => ({
displayUrl: img.displayUrl,
originalUrl: img.originalUrl,
id: img.id,
roomId: img.roomId
}))
)
}
console.log('🔍 [MyAlbum Debug] 最终图片列表:', imagesList)
allImages.value = imagesList
} catch (error) {
console.error('获取图片失败:', error)
if (window.$message) {
window.$message.error('获取图片失败')
}
} finally {
loading.value = false
}
}
/**
* 处理图片点击
*/
const handleImageClick = (image: { displayUrl: string; originalUrl: string; id: string; roomId: string }) => {
console.log('🔍 [MyAlbum Debug] 点击图片:', image)
activeImageUrl.value = image.displayUrl
showImagePreviewRef.value = true
}
onMounted(() => {
console.log('🔍 [MyAlbum Debug] MyAlbum 组件已挂载')
getAllImages()
})
</script>
<style scoped>
/* 可以添加自定义样式 */
</style>
+29 -6
View File
@@ -40,13 +40,14 @@
<div
v-for="item in options"
:key="item.label"
class="flex flex-wrap items-center gap-15px p-[15px_10px_5px_10px]">
@click="item.onClick"
class="flex flex-wrap items-center gap-15px p-[15px_10px_5px_10px] cursor-pointer">
<div>
<svg class="iconpark-icon w-30px h-30px"><use :href="'#' + item.icon"></use></svg>
</div>
<div class="flex-1 text-14px">{{ item.label }}</div>
<div>
<svg @click="handleBack" class="w-20px text-gray h-20px iconpark-icon"><use href="#right"></use></svg>
<svg class="w-20px text-gray h-20px iconpark-icon"><use href="#right"></use></svg>
</div>
</div>
</div>
@@ -67,19 +68,41 @@ const userStore = useUserStore()
const options = ref([
{
icon: 'xiangce',
label: '相册'
label: '相册',
onClick: () => {
router.push('/mobile/mobileMy/myAlbum')
}
},
{
icon: 'shoucang',
label: '收藏'
label: '收藏',
onClick: () => {
// TODO: 跳转到收藏页面
console.log('收藏')
}
},
{
icon: 'wenjian',
label: '文件'
label: '文件',
onClick: () => {
// TODO: 跳转到文件页面
console.log('文件')
}
},
{
icon: 'gexingzhuangban',
label: '个性装扮'
label: '个性装扮',
onClick: () => {
// TODO: 跳转到个性装扮页面
console.log('个性装扮')
}
},
{
icon: 'robot',
label: 'AI助手',
onClick: () => {
router.push('/mobile/mobileMy/aiAssistant')
}
}
])
+12
View File
@@ -42,6 +42,8 @@ import MyMessages from '#/views/my/MyMessages.vue'
import PublishCommunity from '#/views/my/PublishCommunity.vue'
import Share from '#/views/my/Share.vue'
import SimpleBio from '#/views/my/SimpleBio.vue'
import AiAssistant from '#/views/my/AiAssistant.vue'
import MyAlbum from '#/views/my/MyAlbum.vue'
import { TauriCommand } from '@/enums'
import ConfirmQRLogin from '#/views/ConfirmQRLogin.vue'
import MyQRCode from '#/views/MyQRCode.vue'
@@ -251,6 +253,16 @@ const getMobileRoutes = (): Array<RouteRecordRaw> => [
path: 'SimpleBio',
name: 'mobileSimpleBio',
component: SimpleBio
},
{
path: 'aiAssistant',
name: 'mobileAiAssistant',
component: AiAssistant
},
{
path: 'myAlbum',
name: 'mobileMyAlbum',
component: MyAlbum
}
]
},
+88 -4
View File
@@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path'
import { appDataDir, join, resourceDir } from '@tauri-apps/api/path'
import { readDir } from '@tauri-apps/plugin-fs'
import { StoresEnum } from '@/enums'
import { isMobile } from '@/utils/PlatformConstants'
import { useUserStore } from './user'
/**
* 文件信息接口
@@ -43,6 +45,10 @@ export const useFileStore = defineStore(
const getRoomFilesForDisplay = async (roomId: string) => {
const files = roomFilesMap[roomId] ? Object.values(roomFilesMap[roomId]) : []
if (files.length === 0) {
return []
}
const processedFiles = await Promise.all(
files.map(async (file) => {
let displayUrl: string
@@ -51,8 +57,9 @@ export const useFileStore = defineStore(
// HTTP URL 直接使用
displayUrl = file.url
} else {
// 相对路径需要拼接 BaseDirectory.AppData 的绝对路径
const baseDirPath = isMobile() ? await appDataDir() : await appDataDir()
// 相对路径需要拼接基础目录的绝对路径
// 移动端使用 AppData,PC 端使用 Resource
const baseDirPath = isMobile() ? await appDataDir() : await resourceDir()
const absolutePath = await join(baseDirPath, file.url)
displayUrl = convertFileSrc(absolutePath)
}
@@ -115,6 +122,82 @@ export const useFileStore = defineStore(
return roomFilesMap[roomId]?.[fileId]
}
/**
* 扫描本地目录并填充 file store
* 用于初始化时加载已存在的文件
* 注意:此功能仅在移动端可用
*/
const scanLocalFiles = async (roomId: string) => {
// 仅移动端支持扫描本地文件
if (!isMobile()) {
console.warn('scanLocalFiles 仅在移动端可用')
return 0
}
try {
const userStore = useUserStore()
// 获取用户数据目录
const userRoomDir = await userStore.getUserRoomDir()
// 获取绝对路径(移动端使用 AppData)
const baseDirPath = await appDataDir()
const absolutePath = await join(baseDirPath, userRoomDir)
// 读取目录内容
const entries = await readDir(absolutePath)
// 图片和视频扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm']
let addedCount = 0
for (const entry of entries) {
if (!entry.isFile) continue
const fileName = entry.name
const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'))
let fileType: 'image' | 'video' | null = null
let mimeType = ''
if (imageExtensions.includes(ext)) {
fileType = 'image'
mimeType = `image/${ext === '.jpg' ? 'jpeg' : ext.substring(1)}`
} else if (videoExtensions.includes(ext)) {
fileType = 'video'
mimeType = `video/${ext.substring(1)}`
}
if (fileType) {
// 使用文件名作为 ID(因为我们不知道原始消息 ID)
const fileId = fileName.substring(0, fileName.lastIndexOf('.'))
// 构建文件 URL(使用相对路径,与 getUserRoomDir 返回的格式一致)
const fileUrl = await join(userRoomDir, fileName)
addFile({
id: fileId,
roomId,
fileName,
type: fileType,
url: fileUrl,
suffix: ext.substring(1),
mimeType
})
addedCount++
}
}
return addedCount
} catch (error) {
console.error('扫描本地文件失败:', error)
return 0
}
}
return {
// 状态
roomFilesMap: readonly(roomFilesMap),
@@ -128,7 +211,8 @@ export const useFileStore = defineStore(
removeFile,
clearRoomFiles,
getFile,
getRoomFilesForDisplay
getRoomFilesForDisplay,
scanLocalFiles
}
},
{