Browse Source

修改切换账号样式

master
[yxf] 4 weeks ago
parent
commit
f4611b24bb
  1. 845
      im-web/src/components/account/AccountSwitchMenu.vue
  2. 94
      im-web/src/utils/accountManager.js

845
im-web/src/components/account/AccountSwitchMenu.vue

@ -0,0 +1,845 @@
<template>
<div>
<!-- 账号切换菜单主体 -->
<div
class="account-switch-menu"
v-if="visible"
@click.stop
ref="menu"
v-loading="loading">
<!-- 当前账号 -->
<div class="menu-header">
<div class="current-label">当前账号</div>
<div class="current-account">
<img
:src="currentUser.headImageThumb || currentUser.headImage || defaultAvatar"
class="user-avatar"
@error="handleAvatarError">
<div class="account-info">
<div class="nick-name">
{{ currentUser.nickName }}
<el-tag type="success" size="mini" class="role-tag">客服</el-tag>
</div>
<div class="account-name">@{{ currentUser.userName }}</div>
</div>
<i class="el-icon-check current-icon"></i>
</div>
</div>
<!-- 其他账号列表 -->
<div class="other-accounts" v-if="allAccounts.length > 0">
<div class="divider"></div>
<div class="other-label">
可切换账号
<span class="account-count">{{ allAccounts.length }}</span>
</div>
<div
class="account-item"
v-for="account in allAccounts"
:key="account.id"
@click="handleSwitch(account)">
<div class="chat-left">
<img
:src="account.headImageThumb || account.headImage || defaultAvatar"
class="user-avatar"
@error="handleAvatarError">
<!-- 未读消息角标 -->
<div
v-show="account.unreadCount > 0"
class="unread-text">
{{ account.unreadCount > 99 ? '99+' : account.unreadCount }}
</div>
</div>
<div class="account-info">
<div class="nick-name">
{{ account.nickName }}
<el-tag type="success" size="mini" class="role-tag">客服</el-tag>
</div>
<div class="account-name">@{{ account.userName }}</div>
<div class="account-id">ID: {{ account.id }}</div>
</div>
<div class="account-actions">
<el-button
type="primary"
size="small"
@click.stop="handleSwitch(account)">
切换
</el-button>
<i
class="el-icon-close delete-icon"
@click.stop="handleRemove(account)"
title="移除可切换账号">
</i>
</div>
</div>
</div>
<!-- 空状态 -->
<div class="empty-state" v-else-if="!loading">
<i class="el-icon-info"></i>
<p>暂无其他可切换账号</p>
</div>
<!-- 加载中 -->
<div class="loading-state" v-if="loading">
<i class="el-icon-loading"></i>
<p>加载中...</p>
</div>
<div class="divider"></div>
<!-- 底部操作 -->
<div class="menu-footer">
<div class="menu-item add-account" @click="handleAddAccount">
<i class="el-icon-plus"></i>
<span>添加账号</span>
</div>
</div>
</div>
<!-- 添加账号登录对话框 -->
<el-dialog
title="添加账号"
:visible.sync="addAccountDialogVisible"
width="400px"
custom-class="add-account-dialog"
:close-on-click-modal="false"
:modal-append-to-body="true"
:append-to-body="true"
@click.stop=""
>
<el-form :model="loginForm" :rules="loginRules" ref="loginForm" label-width="80px" @submit.native.prevent>
<el-form-item label="账号" prop="userName">
<el-input
v-model="loginForm.userName"
placeholder="请输入账号"
prefix-icon="el-icon-user"
@keyup.enter.native="handleAddAccountLogin">
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="el-icon-lock"
show-password
@keyup.enter.native="handleAddAccountLogin">
</el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="addAccountDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleAddAccountLogin" :loading="adding">添加</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
const ACCOUNTS_CACHE_KEY = 'switchable_accounts_cache'; // key
export default {
name: 'AccountSwitchMenu',
props: {
visible: {
type: Boolean,
default: false
},
currentUser: {
type: Object,
required: true
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
}
},
data() {
return {
allAccounts: [],
loading: false,
adding: false,
addAccountDialogVisible: false,
loginForm: {
userName: '',
password: ''
},
loginRules: {
userName: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
},
defaultAvatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
unreadTimer: null,
wsListenerAdded: false
};
},
watch: {
visible: {
handler(val) {
if (val) {
this.loadAccountList();
this.$nextTick(() => {
this.positionMenu();
});
document.addEventListener('click', this.handleClickOutside);
this.addMessageListener();
this.startUnreadTimer();
} else {
document.removeEventListener('click', this.handleClickOutside);
this.stopUnreadTimer();
}
},
immediate: true
}
},
mounted() {
this.addMessageListener();
this.startUnreadTimer();
},
beforeDestroy() {
document.removeEventListener('click', this.handleClickOutside);
this.stopUnreadTimer();
this.removeMessageListener();
},
methods: {
//
getCachedAccounts() {
try {
const cached = localStorage.getItem(ACCOUNTS_CACHE_KEY);
return cached ? JSON.parse(cached) : [];
} catch (e) {
console.error('读取缓存失败:', e);
return [];
}
},
//
saveCachedAccounts(accounts) {
try {
//
const cacheData = accounts.map(a => ({
id: a.id,
userName: a.userName,
nickName: a.nickName,
headImage: a.headImage,
headImageThumb: a.headImageThumb
}));
localStorage.setItem(ACCOUNTS_CACHE_KEY, JSON.stringify(cacheData));
} catch (e) {
console.error('保存缓存失败:', e);
}
},
//
addAccountToCache(account) {
const accounts = this.getCachedAccounts();
//
const exists = accounts.some(a => a.id === account.id);
if (!exists) {
accounts.push({
id: account.id,
userName: account.userName,
nickName: account.nickName,
headImage: account.headImage,
headImageThumb: account.headImageThumb
});
this.saveCachedAccounts(accounts);
}
},
//
removeAccountFromCache(accountId) {
let accounts = this.getCachedAccounts();
accounts = accounts.filter(a => a.id !== accountId);
this.saveCachedAccounts(accounts);
},
async loadAccountList() {
this.loading = true;
try {
// 1.
let accounts = this.getCachedAccounts();
// 2.
accounts = accounts.filter(a => a.id !== this.currentUser.id);
console.log('从缓存加载账号列表:', accounts);
// 3.
try {
const res = await this.$http({
url: '/user/getSwitchableAccounts',
method: 'post'
});
const data = res.data || res || {};
const serverAccounts = data.switchableUsers || [];
//
if (serverAccounts.length > 0) {
//
const mergedAccounts = this.mergeAccounts(accounts, serverAccounts);
this.saveCachedAccounts(mergedAccounts);
accounts = mergedAccounts.filter(a => a.id !== this.currentUser.id);
}
} catch (error) {
console.log('后端接口获取失败,使用缓存数据:', error);
}
// 4.
if (accounts.length > 0) {
accounts = await this.fetchUnreadCounts(accounts);
}
this.allAccounts = accounts;
} catch (error) {
console.error('加载账号列表失败:', error);
//
const cached = this.getCachedAccounts();
this.allAccounts = cached.filter(a => a.id !== this.currentUser.id);
} finally {
this.loading = false;
}
},
//
mergeAccounts(cached, server) {
const map = new Map();
//
cached.forEach(a => map.set(a.id, a));
// /
server.forEach(a => map.set(a.id, a));
return Array.from(map.values());
},
//
async refreshUnreadCounts() {
if (this.allAccounts.length === 0) return;
try {
const accounts = await this.fetchUnreadCounts(this.allAccounts);
this.allAccounts = accounts;
} catch (error) {
console.error('刷新未读消息数失败:', error);
}
},
//
async fetchUnreadCounts(accounts) {
try {
const userIds = accounts.map(a => a.id);
const res = await this.$http({
url: '/message/private/unreadCounts',
method: 'post',
data: {
userIds: userIds
}
});
const unreadMap = res.data || res || {};
return accounts.map(account => ({
...account,
unreadCount: unreadMap[account.id] || 0
}));
} catch (error) {
console.error('获取未读消息数失败:', error);
return accounts.map(account => ({
...account,
unreadCount: 0
}));
}
},
addMessageListener() {
if (this.wsListenerAdded) return;
this.$eventBus.$on('onPrivateMessage', this.handleNewMessage);
this.$eventBus.$on('onMessageReaded', this.handleMessageReaded);
this.wsListenerAdded = true;
},
removeMessageListener() {
this.$eventBus.$off('onPrivateMessage', this.handleNewMessage);
this.$eventBus.$off('onMessageReaded', this.handleMessageReaded);
this.wsListenerAdded = false;
},
handleNewMessage(msg) {
const account = this.allAccounts.find(a => a.id === msg.recvId);
if (account) {
account.unreadCount = (account.unreadCount || 0) + 1;
this.$forceUpdate();
}
},
handleMessageReaded(data) {
this.refreshUnreadCounts();
},
startUnreadTimer() {
this.stopUnreadTimer();
this.unreadTimer = setInterval(() => {
if (this.visible && this.allAccounts.length > 0) {
this.refreshUnreadCounts();
}
}, 30000);
},
stopUnreadTimer() {
if (this.unreadTimer) {
clearInterval(this.unreadTimer);
this.unreadTimer = null;
}
},
positionMenu() {
const menu = this.$refs.menu || this.$el;
if (!menu || typeof menu.getBoundingClientRect !== 'function') {
setTimeout(() => this.positionMenu(), 10);
return;
}
try {
const rect = menu.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = this.position.x;
let top = this.position.y;
if (left + rect.width > windowWidth - 10) {
left = windowWidth - rect.width - 10;
}
if (top + rect.height > windowHeight - 10) {
top = windowHeight - rect.height - 10;
}
left = Math.max(10, left);
top = Math.max(10, top);
menu.style.left = left + 'px';
menu.style.top = top + 'px';
} catch (e) {
console.error('定位菜单失败', e);
}
},
handleClickOutside(event) {
if (!this.visible) return;
if (this.addAccountDialogVisible) return;
const menu = this.$refs.menu || this.$el;
if (menu && !menu.contains(event.target)) {
const trigger = document.querySelector('.user-head-image');
if (trigger && trigger.contains(event.target)) {
return;
}
this.$emit('close');
}
},
handleAddAccount() {
this.loginForm = {
userName: '',
password: ''
};
this.addAccountDialogVisible = true;
this.$nextTick(() => {
if (this.$refs.loginForm) {
this.$refs.loginForm.clearValidate();
}
});
},
async handleAddAccountLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (!valid) return;
this.adding = true;
try {
const res = await this.$http({
url: '/user/addAccounts',
method: 'post',
data: {
userName: this.loginForm.userName,
password: this.loginForm.password,
terminal: this.getTerminalType()
}
});
//
const accountData = res.data || res || {};
//
if (accountData.user) {
this.addAccountToCache(accountData.user);
} else if (accountData.id) {
this.addAccountToCache(accountData);
}
this.$message.success('添加账号成功');
this.addAccountDialogVisible = false;
await this.loadAccountList();
this.$emit('update');
} catch (error) {
console.error('添加失败:', error);
if (error.response && error.response.data) {
this.$message.error(error.response.data.message || '添加失败');
} else {
this.$message.error('添加失败');
}
} finally {
this.adding = false;
}
});
},
handleSwitch(account) {
//
this.addAccountToCache(account);
//
this.addAccountToCache(this.currentUser);
this.$emit('switch', account);
},
getTerminalType() {
const userAgent = navigator.userAgent;
if (/mobile/i.test(userAgent)) {
return 2;
}
if (/tablet/i.test(userAgent)) {
return 3;
}
return 1;
},
async handleRemove(account) {
try {
await this.$confirm(`确定要移除账号【${account.nickName}】吗?`, '移除账号', {
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning'
});
//
this.removeAccountFromCache(account.id);
//
try {
await this.$http({
url: '/user/removeSwitchableAccount',
method: 'post',
data: {
targetUserId: account.id
}
});
} catch (error) {
console.log('后端移除失败,已从本地缓存移除');
}
this.$message.success('账号已移除');
await this.loadAccountList();
this.$emit('update');
} catch (error) {
//
}
},
handleAvatarError(e) {
e.target.src = this.defaultAvatar;
}
}
};
</script>
<style lang="scss">
.add-account-dialog {
border-radius: 10px !important;
}
.account-switch-menu {
position: fixed;
z-index: 9999;
background: #fff;
border-radius: 12px;
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.2);
width: 380px;
max-width: 90vw;
padding: 12px 0;
user-select: none;
.menu-header {
padding: 8px 16px;
.current-label {
font-size: 12px;
color: #999;
margin-bottom: 10px;
}
.current-account {
display: flex;
align-items: center;
padding: 10px 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
.user-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.3);
flex-shrink: 0;
}
.account-info {
flex: 1;
margin-left: 14px;
.nick-name {
font-size: 16px;
font-weight: 500;
color: #fff;
display: flex;
align-items: center;
.role-tag {
margin-left: 8px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: #fff;
}
}
.account-name {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin-top: 3px;
}
}
.current-icon {
color: #fff;
font-size: 20px;
font-weight: bold;
}
}
}
.divider {
height: 1px;
background: #f0f0f0;
margin: 12px 0;
}
.other-accounts {
padding: 0 4px;
max-height: 320px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 2px;
}
.other-label {
font-size: 12px;
color: #999;
margin-bottom: 10px;
padding-left: 12px;
display: flex;
align-items: center;
justify-content: space-between;
.account-count {
background: #f0f0f0;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
}
}
.account-item {
display: flex;
align-items: center;
padding: 10px 16px;
margin: 2px 0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f7fa;
.delete-icon {
opacity: 1;
}
}
.chat-left {
position: relative;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.unread-text {
position: absolute;
background-color: #f56c6c;
right: -4px;
top: -8px;
color: white;
border-radius: 30px;
padding: 1px 5px;
font-size: 10px;
text-align: center;
white-space: nowrap;
border: 1px solid #f1e5e5;
min-width: 16px;
height: 18px;
line-height: 16px;
}
}
.account-info {
flex: 1;
margin-left: 14px;
min-width: 0;
.nick-name {
font-size: 14px;
font-weight: 500;
color: #333;
display: flex;
align-items: center;
.role-tag {
margin-left: 6px;
transform: scale(0.85);
}
}
.account-name {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.account-id {
font-size: 11px;
color: #bbb;
margin-top: 2px;
}
}
.account-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.delete-icon {
opacity: 0;
color: #999;
font-size: 16px;
padding: 6px;
cursor: pointer;
transition: all 0.2s;
border-radius: 50%;
&:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
}
}
}
}
.empty-state,
.loading-state {
text-align: center;
padding: 30px 20px;
color: #999;
i {
font-size: 36px;
margin-bottom: 8px;
}
p {
margin: 0;
font-size: 13px;
}
}
.menu-footer {
padding: 4px 8px 0;
border-radius: 10px;
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
i {
font-size: 18px;
margin-right: 14px;
width: 20px;
text-align: center;
}
span {
font-size: 14px;
}
&:hover {
background: #f5f7fa;
}
&.add-account {
color: #667eea;
&:hover {
background: rgba(102, 126, 234, 0.08);
}
}
}
}
}
</style>

94
im-web/src/utils/accountManager.js

@ -0,0 +1,94 @@
// utils/accountManager.js
const ACCOUNT_LIST_KEY = 'IM_ACCOUNT_LIST';
const MAX_ACCOUNT_COUNT = 5;
class AccountManager {
// 获取所有保存的账号
static getAccountList() {
try {
const list = localStorage.getItem(ACCOUNT_LIST_KEY);
return list ? JSON.parse(list) : [];
} catch (e) {
console.error('获取账号列表失败', e);
return [];
}
}
// 保存账号列表
static saveAccountList(accounts) {
try {
localStorage.setItem(ACCOUNT_LIST_KEY, JSON.stringify(accounts));
} catch (e) {
console.error('保存账号列表失败', e);
}
}
// 保存或更新账号信息
static saveAccount(accountInfo) {
if (!accountInfo || !accountInfo.account) return;
let accounts = this.getAccountList();
const existingIndex = accounts.findIndex(item => item.account === accountInfo.account);
const newAccount = {
account: accountInfo.account,
nickName: accountInfo.nickName || accountInfo.account,
headImage: accountInfo.headImage || '',
headImageThumb: accountInfo.headImageThumb || '',
lastLoginTime: new Date().getTime(),
userId: accountInfo.userId || accountInfo.id
};
if (existingIndex >= 0) {
accounts[existingIndex] = newAccount;
} else {
accounts.unshift(newAccount);
}
if (accounts.length > MAX_ACCOUNT_COUNT) {
accounts = accounts.slice(0, MAX_ACCOUNT_COUNT);
}
this.saveAccountList(accounts);
}
// 删除账号
static removeAccount(account) {
let accounts = this.getAccountList();
accounts = accounts.filter(item => item.account !== account);
this.saveAccountList(accounts);
}
// 获取其他账号
static getOtherAccounts(currentAccount) {
const accounts = this.getAccountList();
return accounts.filter(item => item.account !== currentAccount);
}
// 设置要切换的账号
static setSwitchAccount(account) {
sessionStorage.setItem('switch_account', account);
}
// 清除切换账号标记
static clearSwitchAccount() {
sessionStorage.removeItem('switch_account');
}
// 格式化时间
static formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前';
return `${date.getMonth() + 1}${date.getDate()}`;
}
}
export default AccountManager;
Loading…
Cancel
Save