前端:
优化electron效果
This commit is contained in:
parent
333391d16f
commit
e43f7d6365
@ -1002,6 +1002,14 @@
|
||||
"runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl/4.3.0": {},
|
||||
"runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl/4.3.0": {},
|
||||
"runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl/4.3.0": {},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"runtime": {
|
||||
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.1.12.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
|
||||
@ -1692,6 +1700,7 @@
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Pomelo.EntityFrameworkCore.MySql": "8.0.3",
|
||||
"RedLock.net": "2.3.2",
|
||||
"SixLabors.ImageSharp": "3.1.12",
|
||||
"StackExchange.Redis": "2.9.32",
|
||||
"Swashbuckle.AspNetCore": "6.6.2",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.14.0"
|
||||
@ -2362,6 +2371,13 @@
|
||||
"path": "runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl/4.3.0",
|
||||
"hashPath": "runtime.ubuntu.16.10-x64.runtime.native.system.security.cryptography.openssl.4.3.0.nupkg.sha512"
|
||||
},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
|
||||
"path": "sixlabors.imagesharp/3.1.12",
|
||||
"hashPath": "sixlabors.imagesharp.3.1.12.nupkg.sha512"
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -20,6 +20,7 @@
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Pomelo.EntityFrameworkCore.MySql": "8.0.3",
|
||||
"RedLock.net": "2.3.2",
|
||||
"SixLabors.ImageSharp": "3.1.12",
|
||||
"StackExchange.Redis": "2.9.32",
|
||||
"Swashbuckle.AspNetCore": "6.6.2",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.14.0"
|
||||
@ -870,6 +871,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"runtime": {
|
||||
"lib/net6.0/SixLabors.ImageSharp.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.1.12.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
|
||||
@ -1564,6 +1573,13 @@
|
||||
"path": "redlock.net/2.3.2",
|
||||
"hashPath": "redlock.net.2.3.2.nupkg.sha512"
|
||||
},
|
||||
"SixLabors.ImageSharp/3.1.12": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
|
||||
"path": "sixlabors.imagesharp/3.1.12",
|
||||
"hashPath": "sixlabors.imagesharp.3.1.12.nupkg.sha512"
|
||||
},
|
||||
"StackExchange.Redis/2.9.32": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -14,7 +14,7 @@
|
||||
"RefreshTokenDays": 30
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;",
|
||||
"DefaultConnection": "Server=192.168.5.100;Port=3306;Database=IM;User=product;Password=12345678;",
|
||||
"Redis": "192.168.5.100:6379"
|
||||
},
|
||||
"RabbitMQOptions": {
|
||||
@ -25,6 +25,6 @@
|
||||
},
|
||||
"FileUploadOptions": {
|
||||
"DefaultStorage": "Local",
|
||||
"ChunkSize": 10,
|
||||
"ChunkSize": 5000000,
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+d42956051108811cdd7be6a07af6b11e1d7f0d15")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+2ecaa28091b41de707825db3628d380b62fa727f")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("IMTest")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@ -1 +1 @@
|
||||
fa24b386648cc4dba48ae5e3f91e5303b0dfd0971bba62bc27ca1580a5064337
|
||||
ed4980dfc7aff253176b260ed9015f9a80b52e92cbf3095eff3ed06865ea6e0d
|
||||
|
||||
Binary file not shown.
@ -1 +1 @@
|
||||
b2b545acb4173028f4d41b8d8c0aea03ecbcecb4eeabe997ca466c2e265beff6
|
||||
a18d4d5688b125e6729fd465f09e267a2a7532eadaaca930389969ac369409ce
|
||||
|
||||
@ -151,3 +151,4 @@ C:\Users\nanxun\Documents\IM\backend\IMTest\bin\Debug\net8.0\Microsoft.Extension
|
||||
C:\Users\nanxun\Documents\IM\backend\IMTest\bin\Debug\net8.0\Microsoft.Extensions.Caching.StackExchangeRedis.dll
|
||||
C:\Users\nanxun\Documents\IM\backend\IMTest\bin\Debug\net8.0\Microsoft.Extensions.Primitives.dll
|
||||
C:\Users\nanxun\Documents\IM\backend\IMTest\bin\Debug\net8.0\System.Diagnostics.DiagnosticSource.dll
|
||||
C:\Users\nanxun\Documents\IM\backend\IMTest\bin\Debug\net8.0\SixLabors.ImageSharp.dll
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -11,6 +11,7 @@ using IM_API.Models.Upload;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs;
|
||||
using IM_API.VOs.Conversation;
|
||||
using IM_API.VOs.Group;
|
||||
using IM_API.VOs.Message;
|
||||
|
||||
namespace IM_API.Configs
|
||||
@ -203,6 +204,17 @@ namespace IM_API.Configs
|
||||
.ForMember(dest => dest.Size, opt => opt.MapFrom(src => src.FileSize));
|
||||
|
||||
CreateMap<ImageDto, VideoDto>();
|
||||
|
||||
//群成员模型
|
||||
CreateMap<UserInfoDto, GroupMemberVo>()
|
||||
.ForMember(dest => dest.Nickname, opt => opt.MapFrom(src => src.NickName))
|
||||
.ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.Username))
|
||||
.ForMember(dest => dest.UserId, opt => opt.MapFrom(src => src.Id))
|
||||
.ForMember(dest => dest.Avatar, opt => opt.MapFrom(src => src.Avatar));
|
||||
|
||||
CreateMap<GroupMember, GroupMemberVo>()
|
||||
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.Created))
|
||||
.ForMember(dest => dest.Role, opt => opt.MapFrom(src => src.RoleEnum));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Dtos.Group;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.VOs.Group;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -67,5 +68,14 @@ namespace IM_API.Controllers
|
||||
var res = new BaseResponse<object?>();
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BaseResponse<List<GroupMemberVo>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetGroupMembers([FromQuery]int groupId)
|
||||
{
|
||||
var useridStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var members = await _groupService.GetGroupMembers(int.Parse(useridStr), groupId);
|
||||
return Ok(new BaseResponse<List<GroupMemberVo>>(members));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using IM_API.Dtos.Group;
|
||||
using IM_API.Models;
|
||||
using IM_API.VOs.Group;
|
||||
|
||||
namespace IM_API.Interface.Services
|
||||
{
|
||||
@ -51,5 +52,6 @@ namespace IM_API.Interface.Services
|
||||
Task HandleGroupRequestAsync(int userid, HandleGroupRequestDto dto);
|
||||
Task MakeGroupRequestAsync(int userId,int? adminUserId,int groupId);
|
||||
Task MakeGroupMemberAsync(int userId, int groupId, GroupMemberRole? role);
|
||||
Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId);
|
||||
}
|
||||
}
|
||||
|
||||
1173
backend/IM_API/Migrations/20260306065353_uploadtask-url.Designer.cs
generated
Normal file
1173
backend/IM_API/Migrations/20260306065353_uploadtask-url.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
backend/IM_API/Migrations/20260306065353_uploadtask-url.cs
Normal file
30
backend/IM_API/Migrations/20260306065353_uploadtask-url.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace IM_API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class uploadtaskurl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Url",
|
||||
table: "upload_tasks",
|
||||
type: "longtext",
|
||||
nullable: true,
|
||||
collation: "utf8mb4_general_ci")
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Url",
|
||||
table: "upload_tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -812,6 +812,9 @@ namespace IM_API.Migrations
|
||||
b.Property<int>("TotalChunks")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PRIMARY");
|
||||
|
||||
|
||||
@ -20,5 +20,6 @@
|
||||
public string? ProviderUploadId { get; set; } // OSS/S3 UploadId
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,8 +34,9 @@ namespace IM_API.Services
|
||||
var privateList = await (from c in _context.Conversations
|
||||
join f in _context.Friends on new { c.UserId, c.TargetId }
|
||||
equals new { UserId = f.UserId, TargetId = f.FriendId }
|
||||
join u in _context.Users on c.TargetId equals u.Id
|
||||
where c.UserId == userId && c.ChatType == ChatType.PRIVATE
|
||||
select new { c, f.Avatar, f.RemarkName })
|
||||
select new { c, u.Avatar, f.RemarkName })
|
||||
.ToListAsync();
|
||||
|
||||
// 2. 获取群聊会话
|
||||
|
||||
@ -2,13 +2,16 @@
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using IM_API.Domain.Events;
|
||||
using IM_API.Dtos.Group;
|
||||
using IM_API.Dtos.User;
|
||||
using IM_API.Exceptions;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models;
|
||||
using IM_API.Tools;
|
||||
using IM_API.VOs.Group;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace IM_API.Services
|
||||
{
|
||||
@ -247,5 +250,21 @@ namespace IM_API.Services
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
public async Task<List<GroupMemberVo>> GetGroupMembers(int userId, int groupId)
|
||||
{
|
||||
var members = await _context.GroupMembers
|
||||
.Where(x => x.GroupId == groupId).ToListAsync();
|
||||
if (members is null || members.Count() == 0)
|
||||
return [];
|
||||
|
||||
var users = await _userService.GetUserInfoListAsync(members.Select(x => x.UserId).ToList());
|
||||
return users.Zip(members, (u, m) =>
|
||||
{
|
||||
var user = _mapper.Map<GroupMemberVo>(u);
|
||||
_mapper.Map(m, user);
|
||||
return user;
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,9 +157,14 @@ namespace IM_API.Services
|
||||
|
||||
public async Task<UploadTask> UploadSmallFileAsync(Stream stream, string fileName, string fileType, long size, string hash)
|
||||
{
|
||||
var request = _httpContext.HttpContext?.Request;
|
||||
var baseUrl = $"{request.Scheme}://{request.Host}";
|
||||
var taskOld = await _uploadTaskService.GetTaskAsync(hash);
|
||||
|
||||
if (taskOld is not null) return taskOld;
|
||||
if (taskOld is not null) {
|
||||
taskOld.Url = UrlTools.GetFullUrl(taskOld.ObjectName, ProviderName, baseUrl);
|
||||
return taskOld;
|
||||
}
|
||||
|
||||
|
||||
var userId = _httpContext.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var objectname = ObjectNameGenerator.Generate(new ObjectNameContext
|
||||
@ -184,6 +189,7 @@ namespace IM_API.Services
|
||||
FileSize = size,
|
||||
Id = Guid.NewGuid(),
|
||||
ObjectName = objectname,
|
||||
Url = UrlTools.GetFullUrl(objectname, ProviderName, baseUrl),
|
||||
ProviderUploadId = Guid.NewGuid().ToString(),
|
||||
Status = UploadStatus.Completed,
|
||||
StorageProvider = ProviderName,
|
||||
|
||||
14
backend/IM_API/VOs/Group/GroupMemberVo.cs
Normal file
14
backend/IM_API/VOs/Group/GroupMemberVo.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using IM_API.Models;
|
||||
|
||||
namespace IM_API.VOs.Group
|
||||
{
|
||||
public class GroupMemberVo
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Nickname { get; set; }
|
||||
public string Avatar { get; set; }
|
||||
public GroupMemberRole Role { get; set; }
|
||||
public DateTimeOffset Created { get; set; }
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ function createWindow() {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
@ -75,9 +76,10 @@ app.whenReady().then(() => {
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
//app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
@ -1,15 +1,68 @@
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import icon from '../../../resources/icon.png?asset'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
|
||||
export function registerWindowHandler() {
|
||||
const windowMapData = new Map()
|
||||
|
||||
export function registerWindowHandler(){
|
||||
ipcMain.on('window-action', (event, action) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
const actions = {
|
||||
minimize: () => win.minimize(),
|
||||
maximize: () => (win.isMaximized() ? win.unmaximize() : win.maximize()),
|
||||
close: () => win.hide(),
|
||||
closeThis: () => {
|
||||
const mainWin = BrowserWindow.fromId(1); // 假设 ID 1 是主窗口
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if(win.id != mainWin?.id){
|
||||
win.destroy()
|
||||
}
|
||||
},
|
||||
isMaximized: () => win.isMaximized()
|
||||
};
|
||||
actions[action]?.();
|
||||
}
|
||||
actions[action]?.()
|
||||
})
|
||||
ipcMain.on('window-new', (event, { route, data }) => {
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设
|
||||
icon: join(__dirname, '../../../resources/icon.png'), // Windows 开发环境预览
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
const winId = win.id
|
||||
windowMapData.set(winId, data)
|
||||
|
||||
// 窗口关闭时,记得清理内存,防止内存泄漏
|
||||
win.on('closed', () => {
|
||||
windowMapData.delete(winId)
|
||||
})
|
||||
|
||||
// 构建 Query 参数
|
||||
const queryStr = `?winId=${winId}`
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
// 开发环境:通常使用 Hash 路由 (如 http://localhost:5173/#/your-route?winId=1)
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/${route}${queryStr}`)
|
||||
} else {
|
||||
// 生产环境:loadFile 必须通过 hash 参数来传递路由和参数
|
||||
// 注意:join 会合并路径,hash 部分需要单独传给 options
|
||||
win.loadFile(join(__dirname, '../../renderer/index.html'), {
|
||||
hash: `${route}${queryStr}`
|
||||
})
|
||||
}
|
||||
})
|
||||
// 3. 增加数据索要接口
|
||||
ipcMain.handle('get-window-data', (event, winId) => {
|
||||
return windowMapData.get(Number(winId))
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { app, Tray, Menu, nativeImage } from 'electron'
|
||||
import path from 'path'
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
let tray = null;
|
||||
|
||||
@ -12,7 +13,10 @@ export function createTry(mainWindow){
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{label: '退出', click: () => app.quit()},
|
||||
{label: '设置', click: () => alert('还没写')}
|
||||
{label: '设置', click: () => {
|
||||
mainWindow.webContents.send('router-ctl', '/settings')
|
||||
mainWindow.show()
|
||||
}}
|
||||
]);
|
||||
tray.setToolTip('IM');
|
||||
tray.setContextMenu(menu);
|
||||
|
||||
@ -7,7 +7,10 @@ const api = {
|
||||
minimize: () => ipcRenderer.send('window-action', 'minimize'),
|
||||
maximize: () => ipcRenderer.send('window-action', 'maximize'),
|
||||
close: () => ipcRenderer.send('window-action', 'close'),
|
||||
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized')
|
||||
closeThis: () => ipcRenderer.send('window-action', 'closeThis'),
|
||||
isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'),
|
||||
newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }),
|
||||
getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src 'self' http://localhost:5202 ws://localhost:5202;
|
||||
img-src 'self' data: blob: https: http:;
|
||||
font-src 'self' data:;">
|
||||
font-src 'self' data:;
|
||||
media-src 'self' blob:;">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -10,14 +10,16 @@ import Alert from '@/components/messages/Alert.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
//import { useSignalRStore } from './stores/signalr';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
onMounted(async () => {
|
||||
const { useSignalRStore } = await import('./stores/signalr');
|
||||
const authStore = useAuthStore();
|
||||
const signalRStore = useSignalRStore();
|
||||
if(authStore.token){
|
||||
signalRStore.initSignalR();
|
||||
onMounted(() => {
|
||||
const router = useRouter()
|
||||
if(window.electron){
|
||||
window.electron.ipcRenderer.on('router-ctl', (e, path) => {
|
||||
router.push(path)
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@ -6,9 +6,12 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="1.5" y="1.5" width="7" height="7" v-if="!isMaximized" />
|
||||
<path d="M3 1h6v6H3z M1 3h6v6H1z" v-else fill="currentColor" fill-opacity="0.5" />
|
||||
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="1.5" y="1.5" width="7" height="7" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 3.5h6v6" stroke="currentColor" stroke-width="1.4" />
|
||||
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn close" @click="close" title="关闭">
|
||||
@ -44,16 +47,19 @@ function close() {
|
||||
<style scoped>
|
||||
/* 窗口控制按钮 */
|
||||
.window-controls {
|
||||
z-index: 999;
|
||||
/* 允许拖动整个窗口 */
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
height: 30px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
/* 允许拖动整个窗口 */
|
||||
-webkit-app-region: no-drag;
|
||||
width: 46px; /* 较宽的点击区域,类似 Win10/11 */
|
||||
width: 46px;
|
||||
/* 较宽的点击区域,类似 Win10/11 */
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@ -64,10 +70,12 @@ function close() {
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<WindowControls/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { previewImages } from 'hevue-img-preview/v3'
|
||||
import {onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import WindowControls from '../WindowControls.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const imageList = ref([]);
|
||||
|
||||
const index = ref(0);
|
||||
|
||||
onMounted(async () => {
|
||||
const winId = route.query.winId;
|
||||
const data = await window.api.window.getWindowData(winId);
|
||||
imageList.value = data.imageList;
|
||||
index.value = data.index;
|
||||
previewImages({
|
||||
imgList: imageList.value.map(m => m.content.url),
|
||||
nowImgIndex: index,
|
||||
clickMaskCLose: false,
|
||||
disabledImgRightClick:true,
|
||||
closeBtn: true,
|
||||
zIndex: 998,
|
||||
closeFn: () => {
|
||||
window.api.window.closeThis();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@ -2,7 +2,7 @@ import { useConversationStore } from "@/stores/conversation"
|
||||
|
||||
export const messageHandler = (msg) => {
|
||||
const conversationStore = useConversationStore();
|
||||
const conversation = conversationStore.conversations.find(x => x.targetId == msg.senderId || x.targetId == msg.receiverId);
|
||||
const conversation = conversationStore.conversations.find(x => (x.targetId == msg.senderId || x.targetId == msg.receiverId) && msg.chatType == x.chatType);
|
||||
conversation.lastMessage = msg.content;
|
||||
if (conversation.targetId == msg.receiverId) {
|
||||
conversation.unreadCount = 0;
|
||||
@ -11,4 +11,4 @@ export const messageHandler = (msg) => {
|
||||
}
|
||||
conversation.dateTime = new Date().toISOString();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +70,9 @@ const routes = [
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{ path: '/test', component: TestView },
|
||||
{
|
||||
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
6
frontend/pc/IM/src/renderer/src/services/userService.js
Normal file
6
frontend/pc/IM/src/renderer/src/services/userService.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { request } from "./api";
|
||||
|
||||
export const userService = {
|
||||
updateUserInfo: (params) => request.post('/User/Profile', params),
|
||||
getInfo: () => request.get('/user/me')
|
||||
}
|
||||
@ -36,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
/**
|
||||
* 登录成功保存状态
|
||||
* @param {String} newToken 用户凭证
|
||||
* @param {String} newToken 用户凭证
|
||||
* @param {*} user 用户信息
|
||||
*/
|
||||
function setLoginInfo(newToken, newRefreshToken, user) {
|
||||
@ -47,6 +47,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.setItem('user_token', newToken);
|
||||
localStorage.setItem('refresh_token', newRefreshToken)
|
||||
localStorage.setItem('user_info', JSON.stringify(user))
|
||||
};
|
||||
|
||||
function setUserInfo(user){
|
||||
userInfo.value = user;
|
||||
localStorage.setItem('user_info',JSON.stringify(user));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -61,5 +66,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('user_info')
|
||||
}
|
||||
|
||||
return { token, refreshToken, userInfo, isLoggedIn, isTokenExpired, setLoginInfo, logout };
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
isTokenExpired,
|
||||
setLoginInfo,
|
||||
setUserInfo,
|
||||
logout
|
||||
}
|
||||
})
|
||||
|
||||
@ -4,8 +4,8 @@ import { friendService } from "@/services/friend";
|
||||
import { useMessage } from "@/components/messages/useAlert";
|
||||
|
||||
export const useContactStore = defineStore('contact', {
|
||||
state: () => ({
|
||||
contacts: [],
|
||||
state: () => ({
|
||||
contacts: []
|
||||
|
||||
}),
|
||||
actions: {
|
||||
@ -42,4 +42,4 @@ export const useContactStore = defineStore('contact', {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
20
frontend/pc/IM/src/renderer/src/stores/settings.js
Normal file
20
frontend/pc/IM/src/renderer/src/stores/settings.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { reactive } from "vue";
|
||||
|
||||
export const useSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
notificationOptions: reactive(JSON.parse(localStorage.getItem('notification_options')) || {}),
|
||||
generalOptions: reactive(JSON.parse(localStorage.getItem('generaN_options')) || {})
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setNotificationOptions(options) {
|
||||
this.notificationOptions = options;
|
||||
localStorage.setItem('notification_options', options)
|
||||
},
|
||||
setGeneralOptions(options){
|
||||
this.generalOptions = options;
|
||||
localStorage.setItem('generaN_options', options)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -6,8 +6,6 @@ import { useChatStore } from "./chat";
|
||||
import { authService } from "@/services/auth";
|
||||
import { generateSessionId } from "@/utils/sessionIdTools";
|
||||
import { messageHandler } from "@/handler/messageHandler";
|
||||
import { useBrowserNotification } from "@/services/useBrowserNotification";
|
||||
import { useConversationStore } from "./conversation";
|
||||
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
|
||||
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import defaultAvatar from '@/assets/default_avatar.png'
|
||||
import { useRouter } from 'vue-router';
|
||||
@ -43,6 +43,15 @@ function handleStartChat(contact) {
|
||||
console.error('Invalid contact object:', contact);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { useSignalRStore } = await import('../stores/signalr');
|
||||
const authStore = useAuthStore();
|
||||
const signalRStore = useSignalRStore();
|
||||
if(authStore.token){
|
||||
signalRStore.initSignalR();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,222 +1,206 @@
|
||||
<template>
|
||||
<div v-if="true" class="modal-mask" @click.self="close">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>添加好友</h2>
|
||||
<button class="icon-close" @click="close">×</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="mask" @click.self="close">
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar" @click.stop>
|
||||
<button @click="prev" title="上一张" v-html="feather.icons['arrow-left'].toSvg({width:15,height15})"></button>
|
||||
<button @click="next" title="下一张">▶</button>
|
||||
<button @click="zoomOut" title="缩小">-</button>
|
||||
<button @click="zoomIn" title="放大">+</button>
|
||||
<button @click="rotate" title="旋转">⟳</button>
|
||||
<button @click="download" title="下载">⬇</button>
|
||||
<button @click="close" title="关闭">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="file"
|
||||
placeholder="搜索 ID / 手机号"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- 图片 -->
|
||||
<img
|
||||
:src="current"
|
||||
class="img"
|
||||
:style="style"
|
||||
@mousedown="onDown"
|
||||
@wheel.prevent="onWheel"
|
||||
@dblclick="toggleZoom"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- 索引 -->
|
||||
<div class="indicator">{{ index + 1 }} / {{ list.length }}</div>
|
||||
|
||||
<!-- 左右翻页大按钮 -->
|
||||
<div class="nav left" @click.stop="prev">‹</div>
|
||||
<div class="nav right" @click.stop="next">›</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { uploadFile } from '@/services/upload/uploader';
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import feather from 'feather-icons'
|
||||
|
||||
// 接收父组件的 v-model 用于控制显示隐藏
|
||||
const props = defineProps(['modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'add-friend']);
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
list: { type: Array, default: ['http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg', 'http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg'] },
|
||||
start: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const userResult = ref(null);
|
||||
const hasSearched = ref(false);
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const close = () => {
|
||||
emit('update:modelValue', false);
|
||||
// 关闭时重置状态
|
||||
userResult.value = null;
|
||||
hasSearched.value = false;
|
||||
keyword.value = '';
|
||||
};
|
||||
const visible = ref(true)
|
||||
const index = ref(0)
|
||||
const scale = ref(1)
|
||||
const rotateDeg = ref(0)
|
||||
const offset = ref({ x: 0, y: 0 })
|
||||
|
||||
const onSearch = async () => {
|
||||
if (!keyword.value) return;
|
||||
let dragging = false
|
||||
let startPos = { x: 0, y: 0 }
|
||||
|
||||
loading.value = true;
|
||||
hasSearched.value = false;
|
||||
watch(() => props.modelValue, v => {
|
||||
visible.value = v
|
||||
if (v) {
|
||||
index.value = props.start
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
// 模拟 API 请求延时
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
hasSearched.value = true;
|
||||
const current = computed(() => props.list[index.value] || '')
|
||||
|
||||
// 模拟搜索结果
|
||||
if (keyword.value === '12345') {
|
||||
userResult.value = {
|
||||
userId: '12345',
|
||||
nickname: '极简猫',
|
||||
avatar: 'https://api.dicebear.com/7.x/beta/svg?seed=Felix'
|
||||
};
|
||||
} else {
|
||||
userResult.value = null;
|
||||
}
|
||||
}, 600);
|
||||
};
|
||||
const style = computed(() => ({
|
||||
transform: `translate(${offset.value.x}px, ${offset.value.y}px) scale(${scale.value}) rotate(${rotateDeg.value}deg)`
|
||||
}))
|
||||
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
uploadFile(file, {
|
||||
onProgress: (p) => {
|
||||
console.log(`当前进度:${p}%`);
|
||||
}
|
||||
});
|
||||
function reset() {
|
||||
scale.value = 1
|
||||
rotateDeg.value = 0
|
||||
offset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
emit('add-friend', userResult.value);
|
||||
// 通常这里会显示“已发送请求”提示
|
||||
close();
|
||||
};
|
||||
function close() { emit('update:modelValue', false) }
|
||||
function prev() { if(index.value>0){ index.value--; reset() } }
|
||||
function next() { if(index.value<props.list.length-1){ index.value++; reset() } }
|
||||
function zoomIn() { scale.value = Math.min(scale.value + 0.2, 5) }
|
||||
function zoomOut() { scale.value = Math.max(scale.value - 0.2, 0.3) }
|
||||
function toggleZoom() { scale.value = scale.value === 1 ? 2 : 1 }
|
||||
function rotate() { rotateDeg.value = (rotateDeg.value + 90) % 360 }
|
||||
|
||||
function onWheel(e){
|
||||
if(e.ctrlKey){ e.deltaY>0?zoomOut():zoomIn() }
|
||||
else { e.deltaY>0?next():prev() }
|
||||
}
|
||||
|
||||
function onDown(e){
|
||||
dragging = true
|
||||
startPos = { x: e.clientX - offset.value.x, y: e.clientY - offset.value.y }
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
function onMove(e){ if(!dragging) return; offset.value={ x:e.clientX-startPos.x, y:e.clientY-startPos.y } }
|
||||
function onUp(){ dragging=false; window.removeEventListener('mousemove',onMove); window.removeEventListener('mouseup',onUp) }
|
||||
|
||||
async function download(){
|
||||
try{
|
||||
const res = await fetch(current.value)
|
||||
const blob = await res.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = current.value.split('/').pop()
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}catch(e){ console.error(e) }
|
||||
}
|
||||
|
||||
function onKey(e){
|
||||
if(!visible.value) return
|
||||
if(e.key==='Escape') close()
|
||||
if(e.key==='ArrowLeft') prev()
|
||||
if(e.key==='ArrowRight') next()
|
||||
if(e.key==='ArrowUp') zoomIn()
|
||||
if(e.key==='ArrowDown') zoomOut()
|
||||
if(e.key==='r'||e.key==='R') rotate()
|
||||
}
|
||||
|
||||
onMounted(()=> window.addEventListener('keydown',onKey))
|
||||
onUnmounted(()=> window.removeEventListener('keydown',onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 遮罩层 */
|
||||
.modal-mask {
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.modal-container {
|
||||
background: #ffffff;
|
||||
width: 360px;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
/* 图片 */
|
||||
.img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
background: #f5f5f7;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(20,20,20,0.85);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: #007aff; /* 经典的克莱因蓝/苹果蓝 */
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
.toolbar button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 22px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all .15s ease;
|
||||
}
|
||||
.toolbar button:hover { background: rgba(255,255,255,.2); transform: scale(1.05);}
|
||||
.toolbar button:active { transform: scale(0.95); }
|
||||
|
||||
/* 用户卡片 */
|
||||
.user-card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
/* 左右翻页按钮 */
|
||||
.nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-top: -32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,.6);
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-action-btn {
|
||||
width: 100%;
|
||||
background: #f0f7ff;
|
||||
color: #007aff;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.nav.left { left: 32px; }
|
||||
.nav.right { right: 32px; }
|
||||
.nav:hover { background: rgba(255,255,255,.2); }
|
||||
|
||||
.add-action-btn:hover {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-slide-enter-active, .fade-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.fade-slide-enter-from, .fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
/* 底部索引 */
|
||||
.indicator {
|
||||
position: absolute;
|
||||
bottom: 28px;
|
||||
font-size: 16px;
|
||||
color: #ddd;
|
||||
background: rgba(0,0,0,.5);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,120 +4,108 @@
|
||||
<header class="chat-header">
|
||||
<span class="title">{{ conversationInfo?.targetName || '未选择会话' }}</span>
|
||||
<div class="actions">
|
||||
<button class="tool-btn" @click="startCall('video')" v-html="feather.icons['video'].toSvg({width:20, height: 20})"></button>
|
||||
<button class="tool-btn" @click="startCall('voice')" v-html="feather.icons['phone-call'].toSvg({width:20, height: 20})"></button>
|
||||
<button class="tool-btn" @click="infoShowHandler" v-html="feather.icons['more-vertical'].toSvg({width:20, height: 20})"></button>
|
||||
<button class="tool-btn" @click="startCall('video')"
|
||||
v-html="feather.icons['video'].toSvg({ width: 20, height: 20 })"></button>
|
||||
<button class="tool-btn" @click="startCall('voice')"
|
||||
v-html="feather.icons['phone-call'].toSvg({ width: 20, height: 20 })"></button>
|
||||
<button class="tool-btn" @click="infoShowHandler"
|
||||
v-html="feather.icons['more-vertical'].toSvg({ width: 20, height: 20 })"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div :class="{'main': !isElectron(), 'main-electron':isElectron()}">
|
||||
<div :class="{ 'main': !isElectron(), 'main-electron': isElectron() }">
|
||||
<div class="chat-history" ref="historyRef">
|
||||
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/>
|
||||
<UserHoverCard ref="userHoverCardRef"/>
|
||||
<ContextMenu ref="menuRef"/>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
|
||||
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError"
|
||||
@retry="loadHistoryMsg" />
|
||||
<UserHoverCard ref="userHoverCardRef" />
|
||||
<ContextMenu ref="menuRef" />
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
|
||||
|
||||
<div class="video-dialog">
|
||||
<div class="close-bar" @click="videoOpen = false">
|
||||
<span>正在播放视频</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="video-dialog">
|
||||
<div class="close-bar" @click="videoOpen = false">
|
||||
<span>正在播放视频</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="player-wrapper">
|
||||
<vue3-video-player
|
||||
:src="videoUrl"
|
||||
poster="https://xxx.jpg"
|
||||
:controls="true"
|
||||
:autoplay="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-wrapper">
|
||||
<vue3-video-player :src="videoUrl" poster="https://xxx.jpg" :controls="true" :autoplay="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="(m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) :m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar) ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<div class="msg-content">
|
||||
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ m.senderName }}</div>
|
||||
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']" @contextmenu.prevent="(e) => handleRightClick(e, m)">
|
||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
|
||||
<img
|
||||
class="image-msg-content"
|
||||
:src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
|
||||
alt="图片消息" @click="imagePreview(m)"
|
||||
>
|
||||
|
||||
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
||||
<div v-if="m.isImgLoading" class="progress-box">
|
||||
<div class="circular-progress">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle class="bg" cx="20" cy="20" r="16" />
|
||||
<circle
|
||||
class="bar"
|
||||
cx="20" cy="20" r="16"
|
||||
:style="{ strokeDashoffset: 100 - (m.progress || 0) }"
|
||||
/>
|
||||
</svg>
|
||||
<span class="pct">{{ m.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i v-if="m.isError" class="error-icon" v-html="feather.icons['alert-circle'].toSvg({width:24, height: 24})"></i>
|
||||
</div>
|
||||
</div>
|
||||
<VideoMsg v-else-if="m.type === FILE_TYPE.Video"
|
||||
:thumbnailUrl="m.localUrl ?? m.content.thumb"
|
||||
:duration="m.content.duration"
|
||||
:w="m.content.w"
|
||||
:h="m.content.h"
|
||||
:uploading="m.isImgLoading"
|
||||
:progress="+m.progress"
|
||||
@play="playHandler(m)"
|
||||
/>
|
||||
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice"
|
||||
:url="m.localUrl ?? m.content.url"
|
||||
:duration="m.content.duration"
|
||||
:isRead="true"
|
||||
:isSelf="m.senderId == myInfo.id"
|
||||
/>
|
||||
<div class="status" v-if="m.senderId == myInfo.id">
|
||||
<i v-if="m.isError" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i>
|
||||
<i v-if="m.isLoading" class="loaderIcon" v-html="feather.icons['loader'].toSvg({width:18, height: 18})"></i>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||
<img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
|
||||
:src="(m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar) ?? defaultAvatar"
|
||||
class="avatar-chat" />
|
||||
|
||||
<div class="msg-content">
|
||||
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{
|
||||
m.senderName }}</div>
|
||||
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']"
|
||||
@contextmenu.prevent="(e) => handleRightClick(e, m)">
|
||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
|
||||
<img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
|
||||
alt="图片消息" @click="imagePreview(m)">
|
||||
|
||||
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
||||
<div v-if="m.isImgLoading" class="progress-box">
|
||||
<div class="circular-progress">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle class="bg" cx="20" cy="20" r="16" />
|
||||
<circle class="bar" cx="20" cy="20" r="16"
|
||||
:style="{ strokeDashoffset: 100 - (m.progress || 0) }" />
|
||||
</svg>
|
||||
<span class="pct">{{ m.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i v-if="m.isError" class="error-icon"
|
||||
v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i>
|
||||
</div>
|
||||
</div>
|
||||
<VideoMsg v-else-if="m.type === FILE_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.content.thumb"
|
||||
:duration="m.content.duration" :w="m.content.w" :h="m.content.h" :uploading="m.isImgLoading"
|
||||
:progress="+m.progress" @play="playHandler(m)" />
|
||||
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice" :url="m.localUrl ?? m.content.url"
|
||||
:duration="m.content.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
|
||||
<div class="status" v-if="m.senderId == myInfo.id">
|
||||
<i v-if="m.isError" style="color: red;"
|
||||
v-html="feather.icons['alert-circle'].toSvg({ width: 18, height: 18 })"></i>
|
||||
<i v-if="m.isLoading" class="loaderIcon"
|
||||
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="msg-time">{{ formatDate(m.timeStamp) }}</span>
|
||||
<span class="msg-time">{{ formatDate(m.timeStamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="chat-footer">
|
||||
<div class="toolbar">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
<label class="tool-btn">
|
||||
<i v-html="feather.icons['file'].toSvg({width:25, height: 25})"></i>
|
||||
<input type="file" hidden @change="handleFile($event.target.files)" />
|
||||
</label>
|
||||
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord" v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="请输入消息..."
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
></textarea>
|
||||
<div class="send-row">
|
||||
<button class="send-btn" :disabled="!input.trim()" @click="sendText">发送(S)</button>
|
||||
</div>
|
||||
</footer>
|
||||
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null" :groupData="conversationInfo"/>
|
||||
<footer class="chat-footer">
|
||||
<div class="toolbar">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
|
||||
</button>
|
||||
<label class="tool-btn">
|
||||
<i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
|
||||
<input type="file" hidden @change="handleFile($event.target.files)" />
|
||||
</label>
|
||||
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord"
|
||||
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
|
||||
</button>
|
||||
</div>
|
||||
<textarea v-model="input" placeholder="请输入消息..." @keydown.enter.exact.prevent="sendText"></textarea>
|
||||
<div class="send-row">
|
||||
<button class="send-btn" :disabled="!input.trim()" @click="sendText">发送(S)</button>
|
||||
</div>
|
||||
</footer>
|
||||
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
|
||||
:groupData="conversationInfo" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@ -150,10 +138,10 @@ import { isElectron } from '../../../utils/electronHelper';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
id:{
|
||||
type: String,
|
||||
required:true
|
||||
}
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const infoSideBarShow = ref(false);
|
||||
@ -162,7 +150,7 @@ const chatStore = useChatStore();
|
||||
const signalRStore = useSignalRStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const message = useMessage();
|
||||
const {sendMessage, sendFileMessage, sendTextMessage} = useSendMessageHandler();
|
||||
const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler();
|
||||
|
||||
const input = ref(''); // 输入框内容
|
||||
const historyRef = ref(null); // 绑定 DOM 用于滚动
|
||||
@ -186,10 +174,10 @@ const videoUrl = ref(null);
|
||||
const videoOpen = ref(false)
|
||||
|
||||
const infoShowHandler = () => {
|
||||
if(infoSideBarShow.value)
|
||||
infoSideBarShow.value = false;
|
||||
else
|
||||
infoSideBarShow.value =true;
|
||||
if (infoSideBarShow.value)
|
||||
infoSideBarShow.value = false;
|
||||
else
|
||||
infoSideBarShow.value = true;
|
||||
}
|
||||
|
||||
|
||||
@ -221,22 +209,31 @@ const getImageStyle = (content) => {
|
||||
|
||||
const imagePreview = (m) => {
|
||||
const imageList = chatStore.messages
|
||||
.filter(x => x.type == 'Image')
|
||||
;
|
||||
.filter(x => x.type == 'Image')
|
||||
;
|
||||
const index = imageList.indexOf(m);
|
||||
previewImages({
|
||||
imgList: imageList.map(m => m.content.url),
|
||||
nowImgIndex: index
|
||||
});
|
||||
if (isElectron()) {
|
||||
const safeData = JSON.parse(JSON.stringify( {
|
||||
imageList,
|
||||
index
|
||||
}));
|
||||
window.api.window.newWindow('imgpre',safeData);
|
||||
} else {
|
||||
previewImages({
|
||||
imgList: imageList.map(m => m.content.url),
|
||||
nowImgIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const startRecord = async () => {
|
||||
try{
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
mediaRecorder.ondataavailable = e => {
|
||||
if(e.data.size > 0) audioChunks.push(e.data);
|
||||
if (e.data.size > 0) audioChunks.push(e.data);
|
||||
}
|
||||
// 录音结束后的处理
|
||||
mediaRecorder.onstop = () => {
|
||||
@ -251,13 +248,13 @@ const startRecord = async () => {
|
||||
});
|
||||
handleFile([audioFile]).finally(() => {
|
||||
// 停止所有轨道以关闭麦克风图标
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecord.value = true;
|
||||
}catch(e){
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
message.error('无法获取麦克风权限!');
|
||||
}
|
||||
@ -330,15 +327,15 @@ const handleRightClick = (e, m) => {
|
||||
},
|
||||
{
|
||||
label: '多选',
|
||||
action: () => {}
|
||||
action: () => { }
|
||||
},
|
||||
{
|
||||
label: '翻译',
|
||||
action: () => {}
|
||||
action: () => { }
|
||||
},
|
||||
{
|
||||
label: '引用',
|
||||
action: () => {}
|
||||
action: () => { }
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
@ -356,7 +353,7 @@ watch(
|
||||
conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0;
|
||||
signalRStore.clearUnreadCount(conversationInfo.value.id);
|
||||
},
|
||||
{deep: true}
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 自动滚动到底部
|
||||
@ -387,30 +384,30 @@ async function handleFile(files) {
|
||||
let info = {};
|
||||
let localUrl = null;
|
||||
let img = null;
|
||||
switch(getMessageType(file.type)){
|
||||
switch (getMessageType(file.type)) {
|
||||
case FILE_TYPE.Image:
|
||||
localUrl = URL.createObjectURL(file);
|
||||
img = await loadImage(localUrl);
|
||||
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
|
||||
break;
|
||||
|
||||
case FILE_TYPE.Video: {
|
||||
const imgBlob = await getVideoThumbnailBlob(file);
|
||||
localUrl = URL.createObjectURL(imgBlob);
|
||||
img = await loadImage(localUrl);
|
||||
case FILE_TYPE.Video: {
|
||||
const imgBlob = await getVideoThumbnailBlob(file);
|
||||
localUrl = URL.createObjectURL(imgBlob);
|
||||
img = await loadImage(localUrl);
|
||||
|
||||
info = new VideoInfo(file.type,'[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
case FILE_TYPE.Voice: {
|
||||
localUrl = URL.createObjectURL(file);
|
||||
info = new VoiceInfo(file.type,'[语音消息]', await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
case FILE_TYPE.Voice: {
|
||||
localUrl = URL.createObjectURL(file);
|
||||
info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
await sendFileMessage(file, conversationInfo,info,localUrl);
|
||||
await sendFileMessage(file, conversationInfo, info, localUrl);
|
||||
}
|
||||
|
||||
function toggleEmoji() {
|
||||
@ -422,20 +419,20 @@ async function loadConversation(conversationId) {
|
||||
const res = await messageService.getConversationById(conversationId);
|
||||
conversationInfo.value = res.data;
|
||||
*/
|
||||
if(conversationStore.conversations.length == 0){
|
||||
await conversationStore.loadUserConversations();
|
||||
}
|
||||
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId));
|
||||
if (conversationStore.conversations.length == 0) {
|
||||
await conversationStore.loadUserConversations();
|
||||
}
|
||||
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId));
|
||||
}
|
||||
|
||||
const initChat = async (newId) => {
|
||||
await loadConversation(newId);
|
||||
if(conversationInfo.value){
|
||||
if (conversationInfo.value) {
|
||||
const sessionid = generateSessionId(
|
||||
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP)
|
||||
await chatStore.swtichSession(sessionid,newId);
|
||||
isFinished.value = false;
|
||||
scrollToBottom();
|
||||
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP)
|
||||
await chatStore.swtichSession(sessionid, newId);
|
||||
isFinished.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
}
|
||||
@ -446,7 +443,7 @@ watch(
|
||||
() => {
|
||||
// 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值
|
||||
if (!conversationStore.conversations.length) {
|
||||
return [props.id,null];
|
||||
return [props.id, null];
|
||||
}
|
||||
|
||||
// 尝试找到当前会话
|
||||
@ -465,9 +462,9 @@ watch(
|
||||
// 场景 A:路由切换 (ID 变了)
|
||||
// 这里的逻辑是:只要切了 ID,先加载本地的给用户看
|
||||
//if (newId !== oldId) {
|
||||
// 注意:这里只是加载本地缓存,不负责去服务器拉新
|
||||
// 真正的“拉新”逻辑交给下面的 isInited 判断
|
||||
await initChat(newId);
|
||||
// 注意:这里只是加载本地缓存,不负责去服务器拉新
|
||||
// 真正的“拉新”逻辑交给下面的 isInited 判断
|
||||
await initChat(newId);
|
||||
//}
|
||||
|
||||
// 场景 B:检测到需要补洞 (isInited 变为 false)
|
||||
@ -482,23 +479,23 @@ watch(
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId);
|
||||
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(newId));
|
||||
if(msgList && msgList.length > 0){
|
||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||
const locaMaxSequenceId = chatStore.maxSequenceId;
|
||||
if(locaMaxSequenceId < (minSequenceId - 1)){
|
||||
chatStore.messages = [];;
|
||||
}
|
||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
if (msgList && msgList.length > 0) {
|
||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||
const locaMaxSequenceId = chatStore.maxSequenceId;
|
||||
if (locaMaxSequenceId < (minSequenceId - 1)) {
|
||||
chatStore.messages = [];;
|
||||
}
|
||||
// 3. 如果有新消息,存入 Store
|
||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
}
|
||||
// 3. 如果有新消息,存入 Store
|
||||
|
||||
|
||||
|
||||
// 4. 关键步骤:手动把状态回正!
|
||||
// 必须重新 find 一次对象,确保引用是对的
|
||||
if (session) {
|
||||
session.isInitialized = true;
|
||||
console.log(`[同步完成] 会话 ${newId} 状态已重置为 true`);
|
||||
session.isInitialized = true;
|
||||
console.log(`[同步完成] 会话 ${newId} 状态已重置为 true`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@ -509,14 +506,14 @@ watch(
|
||||
);
|
||||
|
||||
const initObs = async () => {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
// 只有当 Loading 组件进入视口,且满足触发条件
|
||||
if (entry.isIntersecting) {
|
||||
loadHistoryMsg();
|
||||
}
|
||||
loadHistoryMsg();
|
||||
}
|
||||
}, {
|
||||
root: historyRef.value, // 指定监听容器
|
||||
threshold: 0.1 // 露出 10% 就算触发
|
||||
@ -527,7 +524,7 @@ await nextTick();
|
||||
// 如果 sentinel 是组件,需要拿它底下的 $el
|
||||
observer.observe(loadingRef.value.$el || loadingRef.value);
|
||||
}
|
||||
// 3. 【主动侦测逻辑】
|
||||
// 3. 【主动侦测逻辑】
|
||||
// 如果当前没有在加载,且还没有结束,且内容还没有撑开容器
|
||||
const el = historyRef.value;
|
||||
if (el && !isLoading.value && !isFinished.value) {
|
||||
@ -555,7 +552,8 @@ onUnmounted(() => {
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%; /* 确保占满父容器 */
|
||||
height: 100%;
|
||||
/* 确保占满父容器 */
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
@ -584,6 +582,7 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* 遮罩层:全屏、黑色半透明、固定定位 */
|
||||
.video-overlay {
|
||||
position: fixed;
|
||||
@ -595,7 +594,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999; /* 确保在最顶层 */
|
||||
z-index: 9999;
|
||||
/* 确保在最顶层 */
|
||||
}
|
||||
|
||||
/* 播放器弹窗主体 */
|
||||
@ -606,7 +606,7 @@ onUnmounted(() => {
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 顶部状态栏(包含关闭按钮) */
|
||||
@ -637,15 +637,19 @@ onUnmounted(() => {
|
||||
|
||||
.player-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9; /* 锁定 16:9 比例 */
|
||||
aspect-ratio: 16 / 9;
|
||||
/* 锁定 16:9 比例 */
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* 进场动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@ -679,15 +683,23 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-msg {
|
||||
max-width: 100px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
max-width: 100px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 容器:由计算属性决定宽高 */
|
||||
@ -705,14 +717,16 @@ onUnmounted(() => {
|
||||
.image-msg-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* 关键:裁剪而非拉伸 */
|
||||
object-fit: cover;
|
||||
/* 关键:裁剪而非拉伸 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 覆盖层 */
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
inset: 0; /* 铺满父容器 */
|
||||
inset: 0;
|
||||
/* 铺满父容器 */
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -727,21 +741,27 @@ onUnmounted(() => {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.circular-progress svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.circular-progress circle {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.circular-progress .bg {
|
||||
stroke: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.circular-progress .bar {
|
||||
stroke: #ffffff;
|
||||
stroke-dasharray: 100; /* 这里的 100 对应周长 */
|
||||
stroke-dasharray: 100;
|
||||
/* 这里的 100 对应周长 */
|
||||
transition: stroke-dashoffset 0.2s;
|
||||
}
|
||||
|
||||
.circular-progress .pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@ -750,6 +770,7 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
@ -789,15 +810,18 @@ onUnmounted(() => {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loaderIcon {
|
||||
display: inline-block; /* 必须是 block 或 inline-block 才能旋转 */
|
||||
display: inline-block;
|
||||
/* 必须是 block 或 inline-block 才能旋转 */
|
||||
line-height: 0;
|
||||
animation: spin 1s linear infinite; /* 1秒转一圈,线性速度,无限循环 */
|
||||
animation: spin 1s linear infinite;
|
||||
/* 1秒转一圈,线性速度,无限循环 */
|
||||
}
|
||||
|
||||
.status {
|
||||
@ -809,6 +833,7 @@ onUnmounted(() => {
|
||||
.msg.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg.mine .msg-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="security-content">
|
||||
<p class="section-desc">管理您的账户访问权限和安全验证设置,保护账号安全。</p>
|
||||
|
||||
<div class="security-score">
|
||||
<div class="score-info">
|
||||
<span class="score-label">安全等级:</span>
|
||||
<span class="score-value" :class="securityLevel.type">{{ securityLevel.text }}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: securityLevel.percent, backgroundColor: securityLevel.color }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">账号密码</span>
|
||||
<span class="description">上次修改时间:3个月前</span>
|
||||
</div>
|
||||
<button class="action-link" @click="handleEdit('password')">修改密码</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">绑定邮箱</span>
|
||||
<span class="description">已绑定:ge***@example.com</span>
|
||||
</div>
|
||||
<button class="action-link" @click="handleEdit('email')">更换邮箱</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">二步验证 (2FA)</span>
|
||||
<span class="description">登录时需要输入额外的动态验证码</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="twoFactor">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group danger-zone">
|
||||
<h3 class="group-title">危险区域</h3>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">注销账号</span>
|
||||
<span class="description">永久删除您的账号及所有相关数据,且不可撤销</span>
|
||||
</div>
|
||||
<button class="danger-btn" @click="handleDelete">注销</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
|
||||
const twoFactor = ref(false)
|
||||
|
||||
const securityLevel = computed(() => {
|
||||
// 模拟根据设置状态计算安全等级
|
||||
if (twoFactor.value) {
|
||||
return { text: '极高', type: 'high', color: '#52c41a', percent: '100%' }
|
||||
}
|
||||
return { text: '中等', type: 'mid', color: '#faad14', percent: '65%' }
|
||||
})
|
||||
|
||||
const handleEdit = (type) => {
|
||||
console.log(`正在修改: ${type}`)
|
||||
// 这里可以触发一个 Modal 弹窗
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('确定要注销账号吗?此操作无法恢复!')) {
|
||||
console.log('注销逻辑')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.security-content {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 安全评分条 */
|
||||
.security-score {
|
||||
background: #f8f9fb;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.score-info {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.score-value.high { color: #52c41a; }
|
||||
.score-value.mid { color: #faad14; }
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #eef0f2;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
/* 设置项样式 */
|
||||
.settings-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1890ff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 危险区域样式 */
|
||||
.danger-zone {
|
||||
margin-top: 40px;
|
||||
padding: 16px;
|
||||
border: 1px dashed #ffccc7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.danger-zone .group-title {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.danger-zone .setting-item {
|
||||
border-bottom: none;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: #fff;
|
||||
border: 1px solid #ff4d4f;
|
||||
color: #ff4d4f;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
/* Switch 开关样式复用 */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
transition: .3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider { background-color: #1890ff; }
|
||||
input:checked + .slider:before { transform: translateX(16px); }
|
||||
</style>
|
||||
@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="general-content">
|
||||
<p class="section-desc">配置应用的基础外观与运行行为,提升您的日常使用体验。</p>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3 class="group-title">外观界面</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">外观模式</span>
|
||||
<span class="description">选择您喜欢的主题颜色</span>
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<select v-model="settings.theme">
|
||||
<option value="light">浅色模式</option>
|
||||
<option value="dark">深色模式</option>
|
||||
<option value="system">跟随系统</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">语言 (Language)</span>
|
||||
<span class="description">设置应用显示的界面语言</span>
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<select v-model="settings.language">
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3 class="group-title">应用行为</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">开机自启动</span>
|
||||
<span class="description">在电脑启动时自动运行应用</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.autoStart">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">关闭窗口时</span>
|
||||
<span class="description">设置点击关闭按钮后的行为</span>
|
||||
</div>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" value="tray" v-model="settings.closeBehavior"> 最小化到托盘
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" value="quit" v-model="settings.closeBehavior"> 直接退出应用
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3 class="group-title">存储与缓存</h3>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">清理临时缓存</span>
|
||||
<span class="description">当前缓存大小:{{ cacheSize }} MB</span>
|
||||
</div>
|
||||
<button class="ghost-btn" @click="clearCache" :disabled="isClearing">
|
||||
{{ isClearing ? '清理中...' : '立即清理' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const cacheSize = ref(124.5)
|
||||
const isClearing = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
theme: 'system',
|
||||
language: 'zh-CN',
|
||||
autoStart: true,
|
||||
closeBehavior: 'tray'
|
||||
})
|
||||
|
||||
const clearCache = () => {
|
||||
isClearing.value = true
|
||||
setTimeout(() => {
|
||||
cacheSize.ref = 0
|
||||
isClearing.value = false
|
||||
alert('缓存清理成功')
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.general-content {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 下拉选择框样式 */
|
||||
.select-wrapper select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 单选框组样式 */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.ghost-btn {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.ghost-btn:hover:not(:disabled) {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* 复用 Switch 开关样式 */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
transition: .3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider { background-color: #007bff; }
|
||||
input:checked + .slider:before { transform: translateX(16px); }
|
||||
</style>
|
||||
@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="settings-content">
|
||||
<p class="section-desc">配置您的个性化消息提醒方式,确保不错过重要通知。</p>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3 class="group-title">基础通知</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">系统更新通知</span>
|
||||
<span class="description">当有新版本可用时提醒我</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.systemUpdate">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">新消息提醒</span>
|
||||
<span class="description">收到好友或群组新消息时播放提示音</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.newMsg">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3 class="group-title">通知方式</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">桌面弹窗</span>
|
||||
<span class="description">在屏幕右下角显示通知气泡</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.desktopPopup">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">邮件订阅</span>
|
||||
<span class="description">每周接收一次系统周报</span>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="settings.emailDigest">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button @click="saveSettings" :disabled="isSaving" class="save-btn">
|
||||
{{ isSaving ? '保存中...' : '应用更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
systemUpdate: true,
|
||||
newMsg: true,
|
||||
desktopPopup: false,
|
||||
emailDigest: false
|
||||
})
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true
|
||||
// 模拟请求
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
isSaving.value = false
|
||||
alert('通知设置已更新')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-content {
|
||||
width: 100%;
|
||||
max-width: 520px; /* 稍微宽一点,方便文字展示 */
|
||||
margin: 0 auto;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 纯 CSS 实现的开关组件 */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #007bff; /* 匹配你截图中的主蓝色 */
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* 按钮样式保持一致 */
|
||||
.action-bar {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #0069d9;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,151 @@
|
||||
<template>
|
||||
|
||||
<main class="main-panel">
|
||||
<WindowControls/>
|
||||
<header class="panel-header">
|
||||
<h1>{{ props.currentItemName }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<UserProfileEdit v-if="props.settingItem === 'profile'"/>
|
||||
<NotificationEdit v-else-if="props.settingItem === 'notifications'"/>
|
||||
<AccountSecurity v-else-if="props.settingItem === 'security'"/>
|
||||
<GeneralSettings v-else-if="props.settingItem === 'general'"/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { defineProps } from 'vue';
|
||||
import WindowControls from '../../components/WindowControls.vue';
|
||||
import UserProfileEdit from './UserProfileEdit.vue';
|
||||
import NotificationEdit from './NotificationEdit.vue';
|
||||
import AccountSecurity from './AccountSecurity.vue';
|
||||
import GeneralSettings from './GeneralSettings.vue';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
/**包含 profile*/
|
||||
settingItem: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
currentItemName: {
|
||||
type: String,
|
||||
default: '用户资料'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* 右侧内容样式 */
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 40px 50px 20px;
|
||||
}
|
||||
|
||||
.panel-header h1 { font-size: 26px; color: var(--text-main); margin: 0 0 8px 0; }
|
||||
.panel-header p { font-size: 14px; color: var(--text-dim); margin: 0; }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 0 50px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-label { font-weight: 500; color: var(--text-main); margin-bottom: 4px; }
|
||||
.row-desc { font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
/* 开关组件 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #d1d5db;
|
||||
transition: .3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider { background-color: #0075ae; }
|
||||
input:checked + .slider:before { transform: translateX(22px); }
|
||||
|
||||
/* 底部操作 */
|
||||
.panel-footer {
|
||||
padding: 30px 50px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #000000;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0, 168, 132, 0.2);
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* 响应式:窄容器自动堆叠 */
|
||||
@media (max-width: 650px) {
|
||||
.im-settings-container { flex-direction: column; }
|
||||
.menu-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.menu-list { display: flex; overflow-x: auto; padding: 10px; }
|
||||
.menu-item { white-space: nowrap; width: auto; margin-right: 8px; margin-bottom: 0; }
|
||||
.sidebar-title, .sidebar-footer { display: none; }
|
||||
}
|
||||
</style>
|
||||
@ -3,8 +3,8 @@
|
||||
<section class="menu-sidebar">
|
||||
<h2 class="sidebar-title">系统设置</h2>
|
||||
<div class="menu-list">
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
:key="item.id"
|
||||
:class="['menu-item', { active: activeTab === item.id }]"
|
||||
@click="activeTab = item.id"
|
||||
@ -15,46 +15,17 @@
|
||||
</div>
|
||||
<div class="sidebar-footer">版本 v2.4.0</div>
|
||||
</section>
|
||||
<SettingContent :setting-item="activeTab" :current-item-name="currentMenuName"/>
|
||||
|
||||
<main class="main-panel">
|
||||
<header class="panel-header">
|
||||
<h1>{{ currentMenuName }}</h1>
|
||||
<p>配置您的个性化通讯偏好</p>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<div v-if="activeTab === 'notifications'" class="setting-group">
|
||||
<div v-for="(val, key) in notificationSettings" :key="key" class="setting-row">
|
||||
<div class="row-info">
|
||||
<div class="row-label">{{ key }}</div>
|
||||
<div class="row-desc">开启后系统将实时同步您的通知偏好</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="notificationSettings[key]">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-placeholder">
|
||||
<span class="icon">⚙️</span>
|
||||
<p>{{ currentMenuName }} 功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="panel-footer">
|
||||
<button class="btn-cancel" @click="reset">重置</button>
|
||||
<button class="btn-save" @click="save">保存更改</button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import feather from 'feather-icons'
|
||||
import SettingContent from './SettingContent.vue'
|
||||
|
||||
const activeTab = ref('notifications')
|
||||
const activeTab = ref('profile')
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'profile', name: '个人资料', icon: feather.icons['user-check'].toSvg() },
|
||||
@ -94,6 +65,7 @@ const reset = () => location.reload()
|
||||
background: #ffffff;
|
||||
font-family: sans-serif;
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
/* 左侧样式 */
|
||||
@ -112,11 +84,13 @@ const reset = () => location.reload()
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin: 0;
|
||||
/* 允许拖动整个窗口 */
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@ -127,7 +101,7 @@ const reset = () => location.reload()
|
||||
align-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
/* border-radius: 10px; */
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
transition: 0.2s;
|
||||
@ -151,111 +125,5 @@ const reset = () => location.reload()
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* 右侧内容样式 */
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 40px 50px 20px;
|
||||
}
|
||||
|
||||
.panel-header h1 { font-size: 26px; color: var(--text-main); margin: 0 0 8px 0; }
|
||||
.panel-header p { font-size: 14px; color: var(--text-dim); margin: 0; }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 0 50px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-label { font-weight: 500; color: var(--text-main); margin-bottom: 4px; }
|
||||
.row-desc { font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
/* 开关组件 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #d1d5db;
|
||||
transition: .3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider { background-color: #0075ae; }
|
||||
input:checked + .slider:before { transform: translateX(22px); }
|
||||
|
||||
/* 底部操作 */
|
||||
.panel-footer {
|
||||
padding: 30px 50px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #000000;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0, 168, 132, 0.2);
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* 响应式:窄容器自动堆叠 */
|
||||
@media (max-width: 650px) {
|
||||
.im-settings-container { flex-direction: column; }
|
||||
.menu-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.menu-list { display: flex; overflow-x: auto; padding: 10px; }
|
||||
.menu-item { white-space: nowrap; width: auto; margin-right: 8px; margin-bottom: 0; }
|
||||
.sidebar-title, .sidebar-footer { display: none; }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="profile-edit-wrapper">
|
||||
<div class="avatar-container">
|
||||
<div class="avatar-upload" @click="triggerUpload">
|
||||
<img :src="userInfo.avatar" alt="Avatar" />
|
||||
<div class="mask">更换头像</div>
|
||||
</div>
|
||||
<p class="upload-hint">支持 JPG、PNG 格式,小于 2MB</p>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileRef"
|
||||
@change="onFileChange"
|
||||
accept="image/*"
|
||||
hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="edit-form">
|
||||
<div class="input-item">
|
||||
<label>昵称 <span class="dot">*</span></label>
|
||||
<input
|
||||
v-model.trim="userInfo.nickName"
|
||||
type="text"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <div class="input-item">
|
||||
<label>个人简介</label>
|
||||
<textarea
|
||||
v-model="userInfo.bio"
|
||||
placeholder="简单介绍下自己..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
<span class="counter">{{ userInfo.bio.length }}/100</span>
|
||||
</div> -->
|
||||
|
||||
<div class="action-bar">
|
||||
<button type="submit" :disabled="loading" class="save-btn">
|
||||
{{ loading ? '正在保存...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { uploadService } from '../../services/upload/uploadService'
|
||||
import { userService } from '../../services/userService'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { SYSTEM_BASE_STATUS } from '../../constants/systemBaseStatus'
|
||||
import { useMessage } from '../../components/messages/useAlert'
|
||||
import { getFileHash } from '../../utils/uploadTools'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const fileRef = ref(null)
|
||||
const userInfo = reactive({ ...authStore.userInfo })
|
||||
|
||||
const triggerUpload = () => fileRef.value.click()
|
||||
|
||||
const onFileChange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
const hash = await getFileHash(file)
|
||||
const res = await uploadService.uploadSmallFile(file, hash)
|
||||
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
||||
message.error(res.message)
|
||||
}
|
||||
userInfo.avatar = res.data.url
|
||||
message.success('头像上传成功')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!userInfo.nickName) return message.warning('昵称不可为空')
|
||||
loading.value = true
|
||||
const res = await userService.updateUserInfo(userInfo)
|
||||
loading.value = false
|
||||
if(res.code === SYSTEM_BASE_STATUS.SUCCESS){
|
||||
message.success('修改成功')
|
||||
const {data} = await userService.getInfo();
|
||||
authStore.setUserInfo(data)
|
||||
}else{
|
||||
message.error(res.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-edit-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px; /* 进一步收窄,避免在宽屏下输入框过长 */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 头像微调 */
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.avatar-upload img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.avatar-upload:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 表单对齐优化 */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.input-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-item label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dot { color: #ff4d4f; margin-left: 2px; }
|
||||
|
||||
.input-item input,
|
||||
.input-item textarea {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.input-item input:focus,
|
||||
.input-item textarea:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.counter {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -20px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
/* 按钮位置与大小优化 */
|
||||
.action-bar {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
background: #007bff; /* 匹配你截图中的蓝色 */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #0069d9;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user