fix: connect to AI, Moments management, blocking, contacts, and system configuration

This commit is contained in:
乾乾
2025-11-19 14:55:53 +08:00
committed by Dawn
parent cf6b949050
commit b035b886d0
59 changed files with 5724 additions and 8293 deletions

8259
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/avatar/001.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/avatar/002.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/003.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/004.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/005.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/avatar/006.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/avatar/007.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/008.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/avatar/009.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/avatar/010.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/avatar/011.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/avatar/012.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/013.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/avatar/014.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/avatar/015.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/avatar/016.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/avatar/017.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/avatar/018.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/019.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/avatar/020.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/avatar/021.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/avatar/022.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logoD.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
public/logoL.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

55
src/api/admin.ts Normal file
View File

@@ -0,0 +1,55 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/**
* 黑名单统计
*/
export interface BlackStats {
/** 今日新增黑名单 */
todayNew: number
/** 本周新增黑名单 */
weekNew: number
/** 黑名单总数 */
total: number
}
/**
* AI 统计
*/
export interface AiStats {
/** 今日调用次数 */
todayCalls: number
/** 本周调用次数 */
weekCalls: number
/** 活跃模型数 */
activeModels: number
}
/**
* 首页统计数据
*/
export interface HomeStatsResponse {
/** 今日活跃用户数 */
todayActiveUser: number
/** 群聊总数 */
totalGroup: number
/** 当前黑名单数 */
blackCount: number
/** 今日 AI 调用次数 */
aiCallToday: number
/** 黑名单统计 */
blackStats: BlackStats
/** AI 统计 */
aiStats: AiStats
}
/**
* 获取首页统计数据
*/
export function getHomeStats(): Promise<HomeStatsResponse> {
return request<HomeStatsResponse>({
url: '/admin/stats/home',
method: 'get',
module: RequestModule.IM
})
}

217
src/api/ai.ts Normal file
View File

@@ -0,0 +1,217 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
export interface ApiKeyItem {
id?: string
name: string
apiKey: string
platform: string
url?: string
status: number
publicStatus?: boolean
}
export interface ApiKeyPageParams {
pageNo: number
pageSize: number
name?: string
platform?: string
status?: number
}
export interface ModelItem {
id?: string
keyId?: string
name: string
avatar?: string
model: string
platform: string
type: number
sort?: number
status: number
temperature?: number
maxTokens?: number
maxContexts?: number
publicStatus?: number
}
export interface ModelPageParams {
pageNo: number
pageSize: number
name?: string
model?: string
platform?: string
publicStatus?: number
}
export interface PageResult<T> {
list: T[]
total: number
}
export interface PlatformItem {
platform: string
label: string
docs?: string
hint?: string
sort: number
status: number
}
export interface ApiKeySimpleItem {
id: string
name: string
platform: string
}
export function getPlatformList(): Promise<PlatformItem[]> {
return request<PlatformItem[]>({
url: '/platform/list',
method: 'get',
module: RequestModule.AI
})
}
export function getApiKeySimpleList(): Promise<ApiKeySimpleItem[]> {
return request<ApiKeySimpleItem[]>({
url: '/api-key/admin/all-list',
method: 'get',
module: RequestModule.AI
})
}
export function getApiKeyAdminPage(params: ApiKeyPageParams): Promise<PageResult<ApiKeyItem>> {
return request<PageResult<ApiKeyItem>>({
url: '/api-key/admin/page',
method: 'get',
params,
module: RequestModule.AI
})
}
export function getModelSimpleList(): Promise<ApiKeySimpleItem[]> {
return request<ApiKeySimpleItem[]>({
url: '/model/admin/all-list',
method: 'get',
module: RequestModule.AI
})
}
export function getApiKeyPage(params: ApiKeyPageParams): Promise<PageResult<ApiKeyItem>> {
return request<PageResult<ApiKeyItem>>({
url: '/api-key/simple-list',
method: 'get',
params,
module: RequestModule.AI
})
}
export function createApiKey(data: ApiKeyItem): Promise<string> {
return request<string>({
url: '/api-key/create',
method: 'post',
data,
module: RequestModule.AI
})
}
export function updateApiKey(data: ApiKeyItem): Promise<boolean> {
return request<boolean>({
url: '/api-key/update',
method: 'put',
data,
module: RequestModule.AI
})
}
export function deleteApiKey(id: string): Promise<boolean> {
return request<boolean>({
url: '/api-key/delete',
method: 'delete',
params: { id },
module: RequestModule.AI
})
}
// 管理员专用接口
export function updateApiKeyAdmin(data: ApiKeyItem): Promise<boolean> {
return request<boolean>({
url: '/api-key/admin/update',
method: 'put',
data,
module: RequestModule.AI
})
}
export function deleteApiKeyAdmin(id: string): Promise<boolean> {
return request<boolean>({
url: '/api-key/admin/delete',
method: 'delete',
params: { id },
module: RequestModule.AI
})
}
export function getModelPage(params: ModelPageParams): Promise<PageResult<ModelItem>> {
return request<PageResult<ModelItem>>({
url: '/model/page',
method: 'get',
params,
module: RequestModule.AI
})
}
export function getModelAdminPage(params: ModelPageParams): Promise<PageResult<ModelItem>> {
return request<PageResult<ModelItem>>({
url: '/model/admin/page',
method: 'get',
params,
module: RequestModule.AI
})
}
export function createModel(data: ModelItem): Promise<string> {
return request<string>({
url: '/model/create',
method: 'post',
data,
module: RequestModule.AI
})
}
export function updateModel(data: ModelItem): Promise<boolean> {
return request<boolean>({
url: '/model/update',
method: 'put',
data,
module: RequestModule.AI
})
}
export function deleteModel(id: string): Promise<boolean> {
return request<boolean>({
url: '/model/delete',
method: 'delete',
params: { id },
module: RequestModule.AI
})
}
// 管理员专用接口
export function updateModelAdmin(data: ModelItem): Promise<boolean> {
return request<boolean>({
url: '/model/admin/update',
method: 'put',
data,
module: RequestModule.AI
})
}
export function deleteModelAdmin(id: string): Promise<boolean> {
return request<boolean>({
url: '/model/admin/delete',
method: 'delete',
params: { id },
module: RequestModule.AI
})
}

113
src/api/imBlack.ts Normal file
View File

@@ -0,0 +1,113 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/** 黑名单记录 */
export interface ImBlackItem {
/** 主键 ID */
id: string
/** 类型1=IP2=UID */
type: number
/** 拉黑目标IP 或 UID */
target: string
/** 截止时间 */
deadline: string
/** 创建时间(继承自基础实体) */
createTime?: string
/** 用户昵称仅当type=2时有值 */
userName?: string
}
/** 黑名单分页查询参数 */
export interface ImBlackPageParams {
/** 页面索引(从 1 开始) */
pageNo: number
/** 页面大小 */
pageSize: number
/** 拉黑类型1=IP2=UID */
type?: number
/** 拉黑目标UID 或 IP */
target?: string
}
/** 黑名单分页返回 */
export interface ImBlackPageResp {
/** 当前页数 */
pageNo: number
/** 每页数量 */
pageSize: number
/** 总记录数 */
totalRecords: number
/** 是否最后一页 */
isLast?: boolean
/** 数据列表 */
list: ImBlackItem[]
}
/** 拉黑请求参数支持IP和UID */
export interface ImBlackReq {
/** 类型1=IP2=UID */
type: number
/** 拉黑目标IP 或 UID */
target: string
/** 截止时间分钟0 表示永久拉黑 */
deadline: number
}
/**
* 黑名单分页查询
*/
export function getImBlackPage(params: ImBlackPageParams): Promise<ImBlackPageResp> {
return request<ImBlackPageResp>({
url: '/user/black/page',
method: 'get',
params,
module: RequestModule.IM
})
}
/**
* 拉黑支持IP和UID
*/
export function addImBlack(data: ImBlackReq): Promise<void> {
return request<void>({
url: '/user/black',
method: 'put',
data,
module: RequestModule.IM
})
}
/** 编辑黑名单请求参数 */
export interface ImBlackEditReq {
/** 黑名单记录 ID */
id: string
/** 类型1=IP2=UID */
type: number
/** 拉黑目标IP 或 UID */
target: string
/** 截止时间yyyy-MM-dd HH:mm:ss格式空字符串表示永久拉黑 */
deadline: string
}
/**
* 编辑黑名单
*/
export function editImBlack(data: ImBlackEditReq): Promise<void> {
return request<void>({
url: '/user/black/edit',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 移除黑名单
*/
export function removeImBlack(data: { id: string }): Promise<void> {
return request<void>({
url: '/user/black/remove',
method: 'post',
data,
module: RequestModule.IM
})
}

117
src/api/imFeed.ts Normal file
View File

@@ -0,0 +1,117 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/** 游标翻页请求参数 */
export interface CursorPageReq {
/** 每页数量 */
pageSize: number
/** 游标,首次查询可不传或传 null/空字符串 */
cursor?: string | null
/** 用户昵称搜索关键词 */
userName?: string
}
/** 游标翻页返回 */
export interface CursorPageResp<T> {
/** 下次查询时携带的游标 */
cursor: string | null
/** 是否最后一页 */
isLast: boolean
/** 数据列表 */
list: T[]
/** 总数 */
total: number
}
/** 朋友圈列表项 */
export interface ImFeedItem {
/** 朋友圈 ID */
id: string
/** 发布人 UID */
uid: string
/** 文案内容 */
content: string
/** 权限privacy/open/partVisible/notAnyone 等 */
permission: string
/** 媒体类型0 文本、1 图片、2 视频 */
mediaType: number
/** 媒体 URL 列表 */
urls?: string[]
/** 点赞数量 */
likeCount?: number
/** 评论数量 */
commentCount?: number
/** 当前登录用户是否已点赞 */
hasLiked?: boolean
/** 点赞用户列表(这里只保留类型提示) */
likeList?: any[]
/** 评论列表(这里只保留类型提示) */
commentList?: any[]
/** 发布人昵称 */
userName?: string
/** 发布人头像 */
userAvatar?: string
/** 创建时间 */
createTime?: string
}
/**
* 获取朋友圈列表
*/
export function getImFeedList(data: CursorPageReq): Promise<CursorPageResp<ImFeedItem>> {
return request<CursorPageResp<ImFeedItem>>({
url: '/feed/list',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 删除朋友圈
*/
export function deleteImFeed(feedId: string): Promise<void> {
return request<void>({
url: '/feed/del',
method: 'post',
data: {
feedId
},
module: RequestModule.IM
})
}
export interface FeedComment {
id: string
feedId: string
uid: string
userName: string
userAvatar?: string
content: string
replyCommentId?: string
replyUid?: string
replyUserName?: string
createTime: string
}
export function getImFeedComments(feedId: string): Promise<FeedComment[]> {
return request<FeedComment[]>({
url: '/feed/comment/all',
method: 'get',
params: {
feedId
},
module: RequestModule.IM
})
}
export function deleteImFeedComment(commentId: string): Promise<void> {
return request<void>({
url: '/feed/comment/del',
method: 'post',
data: {
commentId
},
module: RequestModule.IM
})
}

55
src/api/imFriend.ts Normal file
View File

@@ -0,0 +1,55 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/** 好友信息 */
export interface FriendItem {
/** 好友UID */
uid: string
/** 备注名 */
remark: string
/** 在线状态1=在线2=离线 */
activeStatus: number
/** 不让TA看我 */
hideMyPosts: boolean
/** 不看TA */
hideTheirPosts: boolean
/** 好友用户名 */
name: string
/** 好友账号 */
account: string
/** 好友头像 */
avatar: string
}
/** 游标分页请求 */
export interface CursorPageReq {
/** 页面大小 */
pageSize: number
/** 游标(首次请求不传) */
cursor?: string
}
/** 游标分页返回 */
export interface CursorPageResp<T> {
/** 下一页游标 */
cursor: string
/** 是否最后一页 */
isLast: boolean
/** 数据列表 */
list: T[]
}
/**
* 获取用户的好友列表
*/
export function getFriendList(uid: string, params: CursorPageReq): Promise<CursorPageResp<FriendItem>> {
return request<CursorPageResp<FriendItem>>({
url: '/user/friend/page',
method: 'get',
params: {
...params,
uid
},
module: RequestModule.IM
})
}

329
src/api/imGroup.ts Normal file
View File

@@ -0,0 +1,329 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/**
* 群聊列表项
*/
export interface ImGroupItem {
/** 群聊 id */
groupId: string
/** 房间 id */
roomId: string
/** 群名称 */
groupName: string
/** 群头像 */
avatar?: string
/** 在线人数 */
onlineNum?: number
/** 群成员数 */
memberNum?: number
/** 群号 */
account?: string
/** 备注 */
remark?: string
/** 是否允许扫码直接进群 */
allowScanEnter?: boolean
}
/**
* 群成员信息
*/
export interface ImGroupMember {
/** 群成员表 id */
id: string
/** uid */
uid: string
/** 用户昵称 */
name?: string
/** 我的群昵称 */
myName?: string
/** 账号 */
account?: string
/** 头像 */
avatar?: string
/** 在线状态 1在线 2离线 */
activeStatus?: number
/** 角色ID 1群主 2管理员 3普通成员 4踢出群聊 */
roleId?: number
/** IP 归属地 */
locPlace?: string
/** 最后一次上下线时间 */
lastOptTime?: string
}
/**
* 获取当前账号加入的群聊列表
*/
export function getImGroupList() {
return request<ImGroupItem[]>({
url: '/room/group/list',
method: 'get',
module: RequestModule.IM
})
}
/**
* 群聊分页响应
*/
export interface ImGroupPageResp {
/** 当前页数 */
pageNo: number
/** 每页数量 */
pageSize: number
/** 总记录数 */
totalRecords: number
/** 是否最后一页 */
isLast?: boolean
/** 数据列表 */
list: ImGroupItem[]
}
/**
* 分页查询所有群聊列表(管理员专用,支持按群昵称和群成员昵称搜索)
*/
export function getImGroupPage(params: {
pageNo: number
pageSize: number
groupNameKeyword?: string
memberNameKeyword?: string
}): Promise<ImGroupPageResp> {
return request<ImGroupPageResp>({
url: '/room/group/page',
method: 'get',
params,
module: RequestModule.IM
})
}
/**
* 群公告信息
*/
export interface ImGroupAnnouncement {
/** 公告 ID */
id: string
/** 房间 ID */
roomId: string
/** 发布者 UID */
uid: string
/** 公告内容 */
content: string
/** 是否置顶 */
top: boolean
/** 创建时间 */
createTime: string
/** 更新时间 */
updateTime?: string
}
/**
* 分页响应
*/
export interface PageResponse<T> {
/** 数据列表 */
records: T[]
/** 总数 */
total: number
/** 当前页 */
current: number
/** 每页大小 */
size: number
}
/**
* 获取群公告列表
*/
export function getImGroupAnnouncementList(params: {
roomId: string
current: number
size: number
}) {
return request<PageResponse<ImGroupAnnouncement>>({
url: '/room/announcement/list',
method: 'get',
params,
module: RequestModule.IM
})
}
/**
* 编辑群公告
*/
export function editImGroupAnnouncement(data: {
id: string
roomId: string
content: string
top: boolean
}) {
return request<boolean>({
url: '/room/announcement/edit',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 删除群公告
*/
export function deleteImGroupAnnouncement(params: { id: string }) {
return request<boolean>({
url: '/room/announcement/delete',
method: 'post',
params,
module: RequestModule.IM
})
}
/**
* 修改群成员昵称(管理员专用)
*/
export function updateMemberNickname(data: {
roomId: string
uid: string
myName: string
remark: string
}) {
return request<void>({
url: '/room/group/member/nickname',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 踢出群成员
*/
export function removeMember(data: { roomId: string; uidList: string[] }) {
return request<void>({
url: '/room/group/member',
method: 'delete',
data,
module: RequestModule.IM
})
}
/**
* 添加管理员
*/
export function addAdmin(data: { roomId: string; uidList: string[] }) {
return request<void>({
url: '/room/group/admin',
method: 'put',
data,
module: RequestModule.IM
})
}
/**
* 撤销管理员
*/
export function revokeAdmin(data: { roomId: string; uidList: string[] }) {
return request<void>({
url: '/room/group/admin',
method: 'delete',
data,
module: RequestModule.IM
})
}
/**
* 禁言群成员
*/
export function muteMember(data: { roomId: string; uid: string; duration: number }) {
return request<void>({
url: '/room/group/mute',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 取消禁言
*/
export function unmuteMember(data: { roomId: string; uid: string }) {
return request<void>({
url: '/room/group/unmute',
method: 'post',
data,
module: RequestModule.IM
})
}
/** 简化的群成员信息 */
export interface ImGroupMemberSimple {
/** 用户 UID */
uid: string
/** 昵称 */
name: string
/** 角色 ID1=群主2=管理员3=普通成员4=已被移出 */
roleId: number
/** 在线状态1=在线2=离线 */
activeStatus: number
/** IP 归属地 */
locPlace: string
/** IP 地址 */
ipAddress: string
}
/** 群成员分页响应 */
export interface ImGroupMemberPageResp {
/** 当前页数 */
pageNo: number
/** 每页数量 */
pageSize: number
/** 总记录数 */
totalRecords: number
/** 是否最后一页 */
isLast?: boolean
/** 数据列表 */
list: ImGroupMemberSimple[]
}
/**
* 获取群成员列表
*/
export function getImGroupMemberPage(params: {
roomId: string
pageNo: number
pageSize: number
}): Promise<ImGroupMemberPageResp> {
return request<ImGroupMemberPageResp>({
url: '/room/group/member/page',
method: 'get',
params,
module: RequestModule.IM
})
}
/**
* 修改群信息
*/
export function updateRoomInfo(data: {
id: string
name: string
avatar: string
allowScanEnter: boolean
}): Promise<boolean> {
return request<boolean>({
url: '/room/updateRoomInfo',
method: 'post',
data,
module: RequestModule.IM
})
}
/**
* 解散群聊
*/
export function disbandGroup(data: {
roomId: string
}): Promise<void> {
return request<void>({
url: '/room/group/disband',
method: 'post',
data,
module: RequestModule.IM
})
}

52
src/api/imUser.ts Normal file
View File

@@ -0,0 +1,52 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/** IM用户信息 - 对应后端 UserSearchResp */
export interface ImUser {
/** 用户UID */
uid: string
/** 用户名 */
name: string
/** 账号 */
account: string
/** 头像 */
avatar: string
}
/** IM用户搜索参数 */
export interface ImUserSearchParams {
/** 页面索引从1开始 */
pageNo: number
/** 页面大小 */
pageSize: number
/** 搜索关键词(昵称模糊查询) */
keyword?: string
/** 用户ID精确查询用于回显 */
id?: string
}
/** IM用户搜索返回 */
export interface ImUserSearchResp {
/** 当前页数 */
pageNo: number
/** 每页数量 */
pageSize: number
/** 总记录数 */
totalRecords: number
/** 是否最后一页 */
isLast?: boolean
/** 数据列表 */
list: ImUser[]
}
/**
* 搜索IM用户按昵称模糊查询
*/
export function searchImUser(params: ImUserSearchParams): Promise<ImUserSearchResp> {
return request<ImUserSearchResp>({
url: '/user/search',
method: 'get',
params,
module: RequestModule.IM
})
}

86
src/api/sysConfig.ts Normal file
View File

@@ -0,0 +1,86 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
/**
* 系统配置项
*/
export interface SysConfigItem {
id: string
configName: string
type: string
configKey: string
configValue: string
createTime?: string
updateTime?: string
}
/**
* 系统初始化配置
*/
export interface SystemInit {
logo: string
name: string
roomGroupId: string
qiNiu: {
ossDomain: string
fragmentSize: string
turnSharSize: string
}
}
/**
* 配置查询参数
*/
export interface ConfigQueryParams {
type?: string
configName?: string
configKey?: string
}
/**
* 获取系统初始化配置
*/
export function getSystemInit(): Promise<SystemInit> {
return request<SystemInit>({
url: '/anyTenant/config/init',
method: 'get',
module: RequestModule.SYSTEM
})
}
/**
* 获取配置列表
*/
export function getConfigList(params?: ConfigQueryParams): Promise<SysConfigItem[]> {
return request<SysConfigItem[]>({
url: '/anyTenant/config/list',
method: 'get',
params,
module: RequestModule.SYSTEM
})
}
/**
* 更新配置
*/
export function updateConfig(data: Partial<SysConfigItem>): Promise<boolean> {
return request<boolean>({
url: '/anyTenant/config/update',
method: 'put',
data,
module: RequestModule.SYSTEM
})
}
/**
* 批量更新配置
*/
export function batchUpdateConfig(data: Partial<SysConfigItem>[]): Promise<boolean> {
return request<boolean>({
url: '/anyTenant/config/batchUpdate',
method: 'put',
data,
module: RequestModule.SYSTEM
})
}

View File

@@ -1,6 +1,14 @@
import { request } from '@/utils/http'
import { RequestModule } from '@/enums/request'
import type { UserInfo, PageParams, PageResponse } from '@/types/api'
import type { UserInfo, PageParams } from '@/types/api'
interface PageBaseResp<T> {
pageNo: number
pageSize: number
totalRecords: number
isLast?: boolean
list: T[]
}
/**
* 获取用户列表(分页)
@@ -17,11 +25,29 @@ export function getUserListApi(
})
}
/**
* 搜索用户
* @param params 分页参数和搜索关键词
*/
export function searchUserByNicknameApi(params: {
pageNo: number
pageSize: number
keyword?: string
id?: string
}): Promise<PageBaseResp<UserInfo>> {
return request<PageBaseResp<UserInfo>>({
url: '/user/search',
method: 'get',
params,
module: RequestModule.IM
})
}
/**
* 获取用户详情
* @param id 用户 ID
*/
export function getUserDetailApi(id: number | string): Promise<UserInfo> {
export function getUserDetailApi(id: string): Promise<UserInfo> {
return request<UserInfo>({
url: '/user/detail',
method: 'get',
@@ -60,7 +86,7 @@ export function editUserApi(data: Partial<UserInfo>): Promise<void> {
* 删除用户
* @param data 删除参数
*/
export function deleteUserApi(data: { id?: number; ids?: number[] }): Promise<void> {
export function deleteUserApi(data: { id?: string; ids?: string[] }): Promise<void> {
return request<void>({
url: '/user/del',
method: 'post',
@@ -73,7 +99,7 @@ export function deleteUserApi(data: { id?: number; ids?: number[] }): Promise<vo
* 重置用户密码
* @param data 重置密码参数
*/
export function resetPasswordApi(data: { id: number; password: string }): Promise<void> {
export function resetPasswordApi(data: { id: string; password: string }): Promise<void> {
return request<void>({
url: '/user/resetPassword',
method: 'post',
@@ -86,7 +112,7 @@ export function resetPasswordApi(data: { id: number; password: string }): Promis
* 修改用户状态
* @param data 状态参数
*/
export function updateUserStateApi(data: { id: number; state: boolean }): Promise<void> {
export function updateUserStateApi(data: { id: string; state: boolean }): Promise<void> {
return request<void>({
url: '/user/updateState',
method: 'post',
@@ -139,4 +165,3 @@ export function uploadAvatarApi(file: File): Promise<{ url: string }> {
})
}

View File

@@ -36,7 +36,9 @@ export enum URLEnum {
/**角色*/
ROLE = '/SysRole',
/**租户*/
TENANT = '/SysTenant'
TENANT = '/SysTenant',
/**IM服务*/
IM = '/im'
}
/**权限类型*/
export enum FlagEnum {

View File

@@ -7,7 +7,9 @@ export enum RequestModule {
/** System 模块 - 系统后台 */
SYSTEM = 'system',
/** AI 模块 - AI 相关 */
AI = 'ai'
AI = 'ai',
/** IM 模块 - 即时通讯服务 */
IM = 'im'
}
/**

View File

@@ -19,8 +19,8 @@ export const useLogin = () => {
const signInLoading = ref<boolean>(false)
const formRef = ref(<InstanceType<typeof NForm>>{})
const ruleForm = reactive({
userName: '15830906024',
password: '123456',
userName: '',
password: '',
tenantName: '',
tenantId: '',
tenantUrl: ''

View File

@@ -122,7 +122,7 @@
<n-popover trigger="hover" placement="bottom" :width="250">
<template #trigger>
<n-badge :type="networkIcon" dot processing>
<n-avatar :size="34" :src="url" style="border-radius: 8px" />
<n-avatar :size="34" :src="avatar" style="border-radius: 8px" />
</n-badge>
</template>
<template #header>
@@ -132,7 +132,7 @@
{{ userInfoStore.getCompanyName }}
</n-tag>
<div class="info-content">
<n-avatar :size="64" :src="url" style="border-radius: 8px" />
<n-avatar :size="64" :src="avatar" style="border-radius: 8px" />
<div>
<span>{{ userName }}</span>
<div>{{ email }}</div>
@@ -219,7 +219,7 @@ const message = useMessage()
const store = mainStore()
const userInfoStore = userStore()
const user = userInfoStore.getUser
const { userName, email, url } = user
const { userName, email, avatar } = user
const { BGC, TEXT_COLOR, BGC_OTHER } = storeToRefs(store)
const showModal = ref(false)
const fullIcon = ref(false)

View File

@@ -43,6 +43,69 @@ const routes: Array<RouteRecordRaw> = [
requiresAuth: true
},
component: () => import('@/views/page/Home.vue')
},
{
path: '/im/user',
name: 'ImUser',
meta: {
title: '用户 / 黑名单管理',
requiresAuth: true
},
component: () => import('@/views/page/ImUser.vue')
},
{
path: '/im/black',
name: 'ImBlack',
meta: {
title: '黑名单列表',
requiresAuth: true
},
component: () => import('@/views/page/ImBlack.vue')
},
{
path: '/im/group',
name: 'ImGroup',
meta: {
title: '群聊管理',
requiresAuth: true
},
component: () => import('@/views/page/ImGroup.vue')
},
{
path: '/im/moment',
name: 'ImMoment',
meta: {
title: '朋友圈管理',
requiresAuth: true
},
component: () => import('@/views/page/ImMoment.vue')
},
{
path: '/ai/model',
name: 'AiModel',
meta: {
title: 'AI 能力中心',
requiresAuth: true
},
component: () => import('@/views/page/AiModel.vue')
},
{
path: '/im/config',
name: 'ImConfig',
meta: {
title: 'IM 配置',
requiresAuth: true
},
component: () => import('@/views/page/ImConfig.vue')
},
{
path: '/im/contact',
name: 'ImContact',
meta: {
title: '联系人 / 好友管理',
requiresAuth: true
},
component: () => import('@/views/page/ImContact.vue')
}
]
}

View File

@@ -1,6 +1,15 @@
import { createAxios } from '@/services/request'
import urls from '@/services/urls'
import type { BatchDelete, login, parameter, Response, UpdateUser, User } from '@/services/types'
import type {
BatchDelete,
CursorPageReq,
ImUserSearchParams,
login,
parameter,
Response,
UpdateUser,
User
} from '@/services/types'
const request = createAxios()
@@ -46,5 +55,14 @@ export default {
/*角色分页 请求*/
rolePage: (params: parameter): Promise<Response> => GET(urls.role + '/page', { params }),
/*获取角色列表*/
getRoleList: (): Promise<Response> => GET(urls.role)
getRoleList: (): Promise<Response> => GET(urls.role),
/* ====================IM用户管理==================== */
/*搜索IM用户*/
searchImUser: (params: ImUserSearchParams): Promise<Response> => GET(urls.imUserSearch, { params }),
/* ====================好友管理==================== */
/*获取用户的好友列表*/
getFriendList: (uid: string, params: CursorPageReq): Promise<Response> =>
GET(urls.friendList, { params: { ...params, uid } })
}

View File

@@ -41,7 +41,15 @@ export type Menu = {
export type parameter = {
pageNum: number
pageSize: number
userName: string
userName?: string
}
/*IM用户搜索参数*/
export type ImUserSearchParams = {
pageNo: number
pageSize: number
keyword?: string
id?: string
}
/*登录类型*/
export type login = {
@@ -95,6 +103,51 @@ export type Renew = {
userName: string
password: string
}
/*IM用户信息*/
export type ImUser = {
/** 用户UID */
uid: string
/** 用户名 */
name: string
/** 账号 */
account: string
/** 头像 */
avatar: string
}
/*好友信息*/
export type FriendItem = {
/** 好友uid */
uid: string
/** 好友备注 */
remark: string
/** 在线状态 1在线 2离线 */
activeStatus: number
/** 不让他看我0-允许1-禁止) */
hideMyPosts: boolean
/** 不看他0-允许1-禁止) */
hideTheirPosts: boolean
}
/*游标分页请求*/
export type CursorPageReq = {
/** 页面大小 */
pageSize: number
/** 游标(首次为空) */
cursor?: string
}
/*游标分页响应*/
export type CursorPageResp<T> = {
/** 游标(下次翻页带上这参数) */
cursor: string
/** 是否最后一页 */
isLast: boolean
/** 数据列表 */
list: T[]
}
/*角色*/
export type Role = {
id: string

View File

@@ -11,5 +11,8 @@ export default {
tenantList: `${prefix + URLEnum.TENANT}/tenantList`,
renew: `${prefix + URLEnum.PASS}/renew`,
user: `${prefix + URLEnum.USER}`,
role: `${prefix + URLEnum.ROLE}`
role: `${prefix + URLEnum.ROLE}`,
// IM相关接口
imUserSearch: `${prefix + URLEnum.IM}/user/search`,
friendList: `${prefix + URLEnum.IM}/user/friend/page`
}

View File

@@ -187,7 +187,10 @@ export const userStore = defineStore('localUserInfo', {
if (rawComponent.includes('/basic/system/baseRole/')) {
return 'Role'
}
if (rawComponent.includes('/basic/system/') || rawComponent.includes('/basic/msg/')) {
if (rawComponent.includes('/basic/msg/')) {
return 'MsgCenter'
}
if (rawComponent.includes('/basic/system/')) {
return 'Home'
}

61
src/utils/avatar.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* 用于处理头像相关操作的实用类
*/
export class AvatarUtils {
private static readonly DEFAULT_AVATAR_RANGE = {
start: '001',
end: '022'
}
private static readonly RANGE_START = parseInt(AvatarUtils.DEFAULT_AVATAR_RANGE.start, 10)
private static readonly RANGE_END = parseInt(AvatarUtils.DEFAULT_AVATAR_RANGE.end, 10)
/**
* 检查头像字符串是否为默认头像 (001-022)
* @param avatar - 要检查的头像字符串
* @returns 布尔值指示是否是默认头像
*/
public static isDefaultAvatar(avatar: string): boolean {
// 快速判断如果为空或长度不是3直接返回false
if (!avatar || avatar.length !== 3) return false
// 检查是否全是数字
const num = parseInt(avatar, 10)
if (isNaN(num)) return false
// 数字范围检查 (001-022)
return num >= AvatarUtils.RANGE_START && num <= AvatarUtils.RANGE_END
}
/**
* 根据头像值获取头像URL
* @param avatar - 头像字符串或URL
* @returns 头像URL
*/
public static getAvatarUrl(avatar: string): string {
const DEFAULT = '/logoD.png'
if (!avatar) return DEFAULT
const rawAvatar = avatar.trim()
// 如果是默认头像编号001-022返回对应的webp文件
if (AvatarUtils.isDefaultAvatar(rawAvatar)) {
return `/avatar/${rawAvatar}.webp`
}
// 尝试解析为URL
try {
const parsed = new URL(avatar)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
// 如果是自家预置文件名,可进一步做白名单/正则校验
if (/^[a-z0-9_-]+$/i.test(avatar)) {
return `/avatar/${avatar}.webp`
}
}
return DEFAULT
}
}

View File

@@ -143,8 +143,17 @@ const createAxiosInstance = (): AxiosInstance => {
function handleBusinessError(data: ApiResponse, config: RequestConfig) {
const store = userStore()
const responseCode = String(data.code)
const msg = data.msg || ''
// 获取环境变量配置的错误
// 无权限错误
if (responseCode === '403' || msg.includes('无此权限')) {
if (config.showError !== false) {
window.$message?.error(msg || '无权限访问')
}
return
}
// 获取环境变量配置的错误码(如 401 未登录、token 失效等)
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || []
@@ -227,12 +236,6 @@ function handleBusinessError(data: ApiResponse, config: RequestConfig) {
return
}
// 403 无权限
if (responseCode === '403') {
window.$message?.error(data.msg || '无权限访问')
return
}
// 其他错误
if (config.showError !== false) {
window.$message?.error(data.msg || '请求失败')

View File

@@ -0,0 +1,306 @@
<template>
<!-- 好友列表弹窗 -->
<n-modal
v-model:show="showModal"
:mask-closable="true"
:close-on-esc="true"
:bordered="false"
style="width: 600px"
transform-origin="center">
<n-card
class="friend-list-modal"
:title="`${currentUser?.nickName || currentUser?.userName || '用户'}的好友列表`"
:bordered="false"
size="small"
closable
@close="closeModal">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<n-spin size="large">
<template #description>加载中...</template>
</n-spin>
</div>
<!-- 空状态 -->
<div v-else-if="friendList.length === 0" class="empty-container">
<n-empty description="暂无好友" />
</div>
<!-- 好友列表 -->
<div v-else class="friend-list">
<div v-for="friend in friendList" :key="friend.uid" class="friend-item">
<!-- 头像 -->
<n-avatar
class="friend-avatar"
round
:size="48"
:src="AvatarUtils.getAvatarUrl(friend.avatar)"
fallback-src="/logoD.png" />
<!-- 好友信息 -->
<div class="friend-info">
<div class="friend-name">
{{ friend.remark || friend.name }}
<n-tag
v-if="friend.remark"
:bordered="false"
size="small"
type="info"
class="remark-tag">
备注
</n-tag>
</div>
<div class="friend-meta">
<span class="friend-id">UID: {{ friend.uid }}</span>
<span class="friend-status">
<span :class="['status-dot', friend.activeStatus === 1 ? 'online' : 'offline']"></span>
{{ friend.activeStatus === 1 ? '在线' : '离线' }}
</span>
</div>
</div>
<!-- 权限标签 -->
<div class="friend-permissions">
<n-tag
v-if="friend.hideMyPosts"
:bordered="false"
size="small"
type="warning">
不让TA看我
</n-tag>
<n-tag
v-if="friend.hideTheirPosts"
:bordered="false"
size="small"
type="default">
不看TA
</n-tag>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="!isLast && friendList.length > 0" class="load-more">
<n-button
:loading="loadingMore"
text
type="primary"
@click="loadMore">
加载更多
</n-button>
</div>
<!-- 底部统计 -->
<template #footer>
<div class="modal-footer">
<span class="friend-count"> {{ friendList.length }} 位好友</span>
<n-button @click="closeModal">关闭</n-button>
</div>
</template>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { pageUser } from '@/services/types'
import { getFriendList, type FriendItem } from '@/api/imFriend'
import { AvatarUtils } from '@/utils/avatar'
import { useMessage } from 'naive-ui'
const message = useMessage()
const showModal = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
const currentUser = ref<pageUser | null>(null)
const friendList = ref<FriendItem[]>([])
const cursor = ref<string | undefined>(undefined)
const isLast = ref(false)
// 打开弹窗
const openModal = async (user: pageUser) => {
currentUser.value = user
showModal.value = true
friendList.value = []
cursor.value = undefined
isLast.value = false
await loadFriendList()
}
// 关闭弹窗
const closeModal = () => {
showModal.value = false
}
// 加载好友列表
const loadFriendList = async () => {
if (!currentUser.value?.uid) {
message.error('用户UID不存在')
return
}
loading.value = true
try {
const data = await getFriendList(currentUser.value.uid, {
pageSize: 20,
cursor: cursor.value
})
friendList.value = [...friendList.value, ...data.list]
cursor.value = data.cursor
isLast.value = data.isLast
} catch (error) {
console.error('加载好友列表失败:', error)
message.error('加载好友列表失败')
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = async () => {
if (isLast.value || loadingMore.value) return
loadingMore.value = true
try {
await loadFriendList()
} finally {
loadingMore.value = false
}
}
defineExpose({
openModal
})
</script>
<style scoped lang="scss">
.friend-list-modal {
max-height: 80vh;
display: flex;
flex-direction: column;
:deep(.n-card__content) {
flex: 1;
overflow-y: auto;
padding: 16px;
}
:deep(.n-card__footer) {
border-top: 1px solid #e8e8e8;
padding: 12px 16px;
}
}
.loading-container,
.empty-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.friend-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.friend-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: #f0f0f0;
transform: translateX(4px);
}
.friend-avatar {
flex-shrink: 0;
}
.friend-info {
flex: 1;
min-width: 0;
.friend-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #262626;
margin-bottom: 4px;
.remark-tag {
font-size: 11px;
}
}
.friend-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #8c8c8c;
.friend-id {
font-family: 'Courier New', monospace;
}
.friend-status {
display: flex;
align-items: center;
gap: 4px;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: #52c41a;
box-shadow: 0 0 4px rgba(82, 196, 26, 0.5);
}
&.offline {
background: #d9d9d9;
}
}
}
}
}
.friend-permissions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
}
.load-more {
display: flex;
justify-content: center;
padding: 16px 0;
border-top: 1px dashed #e8e8e8;
margin-top: 12px;
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
.friend-count {
font-size: 13px;
color: #8c8c8c;
}
}
</style>

View File

@@ -0,0 +1,546 @@
<template>
<!-- 用户画像弹窗 - 微博风格 -->
<n-modal
v-model:show="showProfileModal"
:mask-closable="true"
:close-on-esc="true"
:bordered="false"
style="width: 480px"
transform-origin="center">
<n-card
class="user-profile-card"
:bordered="false"
size="small"
:segmented="{
content: false,
footer: false
}">
<!-- 顶部背景区域 -->
<div class="profile-header">
<!-- 背景渐变 -->
<div class="header-bg"></div>
<!-- 装饰性圆点 -->
<div class="decoration-dot decoration-dot-1"></div>
<div class="decoration-dot decoration-dot-2"></div>
<div class="decoration-dot decoration-dot-3"></div>
<!-- 关闭按钮 -->
<div class="close-btn" @click="closeModal">
<span class="close-icon"></span>
</div>
</div>
<!-- 主体内容区 -->
<div class="profile-content">
<!-- 头像区域 - 向上偏移 -->
<div class="avatar-section">
<n-avatar
class="user-avatar"
round
:size="90"
:src="userProfile?.avatar || '/logoD.png'"
fallback-src="/logoD.png" />
<!-- 状态指示器 -->
<div :class="['status-dot', userProfile?.status === 1 ? 'status-online' : 'status-offline']"></div>
</div>
<!-- 用户名和账号 -->
<div class="user-info">
<h2 class="user-name">{{ userProfile?.nickName || userProfile?.userName || '未知用户' }}</h2>
<p class="user-id">ID: {{ userProfile?.userName || '-' }}</p>
</div>
<!-- 统计数据 - 微博风格 -->
<div class="stats-section">
<div class="stat-item" @click="handleStatClick('integrity')">
<div class="stat-value">{{ calculateIntegrity(userProfile) }}%</div>
<div class="stat-label">资料完整度</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item" @click="handleStatClick('role')">
<div class="stat-value">{{ getRoleText(userProfile?.role) }}</div>
<div class="stat-label">用户角色</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item" @click="handleStatClick('status')">
<div :class="['stat-value', userProfile?.status === 1 ? 'text-success' : 'text-error']">
{{ userProfile?.status === 1 ? '启用' : '禁用' }}
</div>
<div class="stat-label">账号状态</div>
</div>
</div>
<!-- 详细信息卡片 -->
<div class="info-cards">
<!-- 联系方式 -->
<div v-if="userProfile?.mobile || userProfile?.email" class="info-card">
<div class="card-title">
<span class="title-icon">📞</span>
<span>联系方式</span>
</div>
<div class="card-content">
<div v-if="userProfile?.mobile" class="info-row">
<span class="info-label">手机号</span>
<span class="info-value">{{ userProfile.mobile }}</span>
</div>
<div v-if="userProfile?.email" class="info-row">
<span class="info-label">邮箱</span>
<span class="info-value">{{ userProfile.email }}</span>
</div>
</div>
</div>
<!-- 账号信息 -->
<div class="info-card">
<div class="card-title">
<span class="title-icon"></span>
<span>账号信息</span>
</div>
<div class="card-content">
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{ formatDate(userProfile?.createTime) }}</span>
</div>
<div v-if="userProfile?.updateTime" class="info-row">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatDate(userProfile?.updateTime) }}</span>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<n-button
class="action-btn"
type="primary"
size="large"
strong
@click="handleEdit">
<template #icon>
<span style="font-size: 18px"></span>
</template>
编辑资料
</n-button>
</div>
</div>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { pageUser } from '@/services/types'
import { RoleEnum } from '@/enums'
import { handRelativeTime } from '@/utils/Day'
const showProfileModal = ref(false)
const userProfile = ref<pageUser | null>(null)
const emit = defineEmits<{
edit: [user: pageUser]
}>()
// 打开弹窗
const openProfile = (user: pageUser) => {
userProfile.value = user
showProfileModal.value = true
}
// 关闭弹窗
const closeModal = () => {
showProfileModal.value = false
}
// 获取角色文本
const getRoleText = (role?: string) => {
switch (role) {
case RoleEnum.HL_ROOT:
return '超级管理员'
case RoleEnum.HL_SYS_MANAGE:
return '系统管理员'
default:
return '普通用户'
}
}
// 计算资料完整度
const calculateIntegrity = (user: pageUser | null) => {
if (!user) return 0
const nullCount = Object.values(user).filter((value) => value === null).length
const totalDataCount = Object.keys(user).length
return Math.round((1 - nullCount / totalDataCount) * 100)
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
return handRelativeTime(dateStr)
}
// 处理编辑
const handleEdit = () => {
if (userProfile.value) {
emit('edit', userProfile.value)
closeModal()
}
}
// 处理统计项点击
const handleStatClick = (type: string) => {
// 可以在这里添加统计项点击的逻辑
console.log('Stat clicked:', type)
}
defineExpose({
openProfile
})
</script>
<style scoped lang="scss">
.user-profile-card {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
animation: fadeInScale 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
:deep(.n-card__content) {
padding: 0 !important;
}
}
.profile-header {
position: relative;
height: 120px;
overflow: hidden;
.header-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 200% 200%;
animation: gradientShift 10s ease infinite;
}
.decoration-dot {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
animation: float 3s ease-in-out infinite;
&-1 {
top: 20px;
right: 60px;
width: 24px;
height: 24px;
animation-delay: 0s;
}
&-2 {
bottom: 20px;
left: 40px;
width: 18px;
height: 18px;
animation-delay: 1s;
}
&-3 {
top: 50%;
left: 20px;
width: 14px;
height: 14px;
animation-delay: 2s;
}
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
color: white;
z-index: 10;
.close-icon {
font-size: 20px;
font-weight: bold;
line-height: 1;
}
&:hover {
background: rgba(255, 255, 255, 0.4);
transform: scale(1.15) rotate(90deg);
}
&:active {
transform: scale(1.05) rotate(90deg);
}
}
}
.profile-content {
position: relative;
padding: 0 24px 24px;
}
.avatar-section {
position: relative;
margin-top: -50px;
margin-bottom: 16px;
text-align: center;
.user-avatar {
border: 5px solid white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
}
.status-dot {
position: absolute;
bottom: 8px;
right: 50%;
transform: translateX(35px);
width: 18px;
height: 18px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
&.status-online {
background: #52c41a;
animation: pulse 2s ease-in-out infinite;
}
&.status-offline {
background: #d9d9d9;
}
}
}
.user-info {
text-align: center;
margin-bottom: 20px;
.user-name {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 8px 0;
letter-spacing: 0.5px;
}
.user-id {
font-size: 14px;
color: #8c8c8c;
margin: 0;
font-family: 'Courier New', monospace;
}
}
.stats-section {
display: flex;
align-items: stretch;
justify-content: space-between;
padding: 0;
margin-bottom: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #f0f2f5 100%);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 12px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
&:hover {
background: rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
.stat-value {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
line-height: 1;
&.text-success {
color: #52c41a;
}
&.text-error {
color: #ff4d4f;
}
}
.stat-label {
font-size: 12px;
color: #8c8c8c;
font-weight: 500;
}
}
.stat-divider {
width: 1px;
background: linear-gradient(to bottom, transparent, #d9d9d9, transparent);
margin: 12px 0;
}
}
.info-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
.info-card {
background: white;
border: 1px solid #e8e8e8;
border-radius: 10px;
padding: 14px 16px;
transition: all 0.3s ease;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: #262626;
.title-icon {
font-size: 16px;
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 10px;
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
.info-label {
color: #8c8c8c;
font-weight: 500;
}
.info-value {
color: #262626;
font-weight: 500;
text-align: right;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
.action-buttons {
display: flex;
gap: 10px;
.action-btn {
flex: 1;
height: 44px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.3);
}
&:active {
transform: translateY(0);
}
}
}
// 卡片入场动画
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
// 渐变背景动画
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
// 浮动动画
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// 脉冲动画
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>

View File

@@ -36,6 +36,9 @@ export const userTable = (data: Ref<any[]>) => {
const { pagingLoad, contentData, rawData, showDrawer, total } = useBase()
const checkedRowKeys = ref<DataTableRowKey[]>([])
// 用户画像弹窗引用
const userProfileModalRef = ref<any>(null)
/*受控过滤器*/
const statusColumn = reactive<DataTableBaseColumn<pageUser>>({
title: t('status'),
@@ -90,8 +93,14 @@ export const userTable = (data: Ref<any[]>) => {
render: (row) => {
return (
<NSpace justify={'start'} align={'center'}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<NAvatar size={'large'}></NAvatar>
<div
class="user-avatar-wrapper"
onClick={() => handleShowProfile(row)}>
<NAvatar
size={'large'}
src={row.avatar || '/logoD.png'}
fallbackSrc={'/logoD.png'}
></NAvatar>
</div>
<NSpace vertical size={5}>
<p
@@ -324,12 +333,21 @@ export const userTable = (data: Ref<any[]>) => {
checkedRowKeys.value = rowKeys
}
/*显示用户画像*/
const handleShowProfile = (row: pageUser) => {
if (userProfileModalRef.value) {
userProfileModalRef.value.openProfile(row)
}
}
return {
columns,
statusColumn,
pagination,
checkedRowKeys,
handleCheck
handleCheck,
userProfileModalRef,
handleShowProfile
}
}

630
src/views/page/AiModel.vue Normal file
View File

@@ -0,0 +1,630 @@
<template>
<div class="p-4">
<n-card title="AI 能力中心">
<n-tabs type="line" animated>
<!-- API Key 管理 -->
<n-tab-pane name="apiKey" tab="API Key 管理">
<div class="py-4">
<div class="mb-4">
<n-form inline :model="apiKeySearchForm">
<n-form-item label="名称/平台">
<n-input
v-model:value="apiKeySearchForm.keyword"
class="w-52"
clearable
placeholder="请输入名称或平台" />
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleApiKeySearch">查询</n-button>
<n-button @click="handleApiKeyReset">重置</n-button>
<n-button type="primary" @click="handleAddApiKey">添加 API Key</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<n-data-table
:columns="apiKeyColumns"
:data="apiKeyList"
:loading="apiKeyLoading"
:pagination="apiKeyPagination"
:bordered="false"
striped />
</div>
</n-tab-pane>
<!-- AI 模型管理 -->
<n-tab-pane name="model" tab="AI 模型管理">
<div class="py-4">
<div class="mb-4">
<n-form inline :model="modelSearchForm">
<n-form-item label="名称/标识">
<n-input
v-model:value="modelSearchForm.keyword"
class="w-52"
clearable
placeholder="请输入名称或标识" />
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleModelSearch">查询</n-button>
<n-button @click="handleModelReset">重置</n-button>
<n-button type="primary" @click="handleAddModel">添加模型</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<n-data-table
:columns="modelColumns"
:data="modelList"
:loading="modelLoading"
:pagination="modelPagination"
:bordered="false"
striped />
</div>
</n-tab-pane>
</n-tabs>
</n-card>
<!-- API Key 编辑弹窗 -->
<n-modal
v-model:show="showApiKeyModal"
preset="card"
:title="apiKeyModalTitle"
style="width: 800px"
:mask-closable="false">
<n-form
ref="apiKeyFormRef"
:model="apiKeyForm"
:rules="apiKeyRules"
label-placement="left"
label-width="100px">
<n-form-item label="名称" path="name">
<n-input v-model:value="apiKeyForm.name" placeholder="请输入名称" />
</n-form-item>
<n-form-item label="平台" path="platform">
<n-select
v-model:value="apiKeyForm.platform"
:options="platformOptions"
placeholder="请选择平台" />
</n-form-item>
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="apiKeyForm.apiKey"
type="textarea"
placeholder="请输入 API Key"
:rows="3" />
</n-form-item>
<n-form-item label="自定义 URL" path="url">
<n-input v-model:value="apiKeyForm.url" placeholder="可选,自定义 API 地址" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-switch v-model:value="apiKeyForm.status" :checked-value="0" :unchecked-value="1">
<template #checked>启用</template>
<template #unchecked>禁用</template>
</n-switch>
</n-form-item>
<n-form-item label="公开状态" path="publicStatus">
<n-switch v-model:value="apiKeyForm.publicStatus">
<template #checked>公开</template>
<template #unchecked>私有</template>
</n-switch>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="showApiKeyModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveApiKey" :loading="apiKeySaving">保存</n-button>
</div>
</template>
</n-modal>
<!-- 模型编辑弹窗 -->
<n-modal
v-model:show="showModelModal"
preset="card"
:title="modelModalTitle"
style="width: 800px"
:mask-closable="false">
<n-form
ref="modelFormRef"
:model="modelForm"
:rules="modelRules"
label-placement="left"
label-width="100px">
<n-form-item label="模型名称" path="name">
<n-input v-model:value="modelForm.name" placeholder="请输入模型名称" />
</n-form-item>
<n-form-item label="模型标识" path="model">
<n-input v-model:value="modelForm.model" placeholder="如: gpt-3.5-turbo" />
</n-form-item>
<n-form-item label="平台" path="platform">
<n-select
v-model:value="modelForm.platform"
:options="platformOptions"
placeholder="请选择平台" />
</n-form-item>
<n-form-item label="API Key" path="keyId">
<n-select
v-model:value="modelForm.keyId"
:options="apiKeySimpleOptions"
placeholder="请选择 API Key" />
</n-form-item>
<n-form-item label="模型类型" path="type">
<n-select
v-model:value="modelForm.type"
:options="modelTypeOptions"
placeholder="请选择模型类型" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-switch v-model:value="modelForm.status" :checked-value="0" :unchecked-value="1">
<template #checked>启用</template>
<template #unchecked>禁用</template>
</n-switch>
</n-form-item>
<n-form-item label="公开状态" path="publicStatus">
<n-switch v-model:value="modelForm.publicStatus" :checked-value="0" :unchecked-value="1">
<template #checked>公开</template>
<template #unchecked>私有</template>
</n-switch>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="showModelModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveModel" :loading="modelSaving">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, h, onMounted, computed } from 'vue'
import { NButton, NTag, NSwitch, useMessage, useDialog, type DataTableColumns, type FormRules } from 'naive-ui'
import {
createApiKey,
updateApiKeyAdmin,
deleteApiKeyAdmin,
getApiKeyAdminPage,
getModelAdminPage,
createModel,
updateModelAdmin,
deleteModelAdmin,
getPlatformList,
getApiKeySimpleList,
type ApiKeyItem,
type ModelItem
} from '@/api/ai'
const message = useMessage()
const dialog = useDialog()
const apiKeySearchForm = ref({
keyword: ''
})
const modelSearchForm = ref({
keyword: ''
})
const apiKeyList = ref<ApiKeyItem[]>([])
const apiKeyLoading = ref(false)
const apiKeyPagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page: number) => {
apiKeyPagination.value.page = page
loadApiKeyList()
}
})
const modelList = ref<ModelItem[]>([])
const modelLoading = ref(false)
const modelPagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page: number) => {
modelPagination.value.page = page
loadModelList()
}
})
// API Key 表单
const showApiKeyModal = ref(false)
const apiKeyFormRef = ref()
const apiKeySaving = ref(false)
const apiKeyForm = ref({
id: undefined as string | undefined,
name: '',
platform: '',
apiKey: '',
url: '',
status: 0,
publicStatus: false
})
const apiKeyModalTitle = computed(() => (apiKeyForm.value.id ? '编辑 API Key' : '添加 API Key'))
// 模型表单
const showModelModal = ref(false)
const modelFormRef = ref()
const modelSaving = ref(false)
const modelForm = ref({
id: undefined as string | undefined,
name: '',
model: '',
platform: '',
keyId: undefined as string | undefined,
type: 1,
status: 0,
sort: 0,
publicStatus: 0
})
const modelModalTitle = computed(() => (modelForm.value.id ? '编辑模型' : '添加模型'))
const platformOptions = ref<Array<{ label: string; value: string }>>([])
const apiKeySimpleOptions = ref<Array<{ label: string; value: string }>>([])
const modelTypeOptions = [
{ label: '聊天模型', value: 1 },
{ label: '图片模型', value: 2 },
{ label: '音乐模型', value: 3 }
]
const loadPlatformList = async () => {
try {
const data = await getPlatformList()
platformOptions.value = data.map((item) => ({
label: item.label,
value: item.platform
}))
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
const loadApiKeySimpleList = async () => {
try {
const data = await getApiKeySimpleList()
apiKeySimpleOptions.value = data.map((item) => ({
label: `${item.name} (${item.platform})`,
value: item.id
}))
} catch (error) {
console.error('加载 API Key 列表失败:', error)
}
}
// 表单验证规则
const apiKeyRules: FormRules = {
name: { required: true, message: '请输入名称', trigger: 'blur' },
platform: { required: true, message: '请选择平台', trigger: 'change' },
apiKey: { required: true, message: '请输入 API Key', trigger: 'blur' }
}
const modelRules: FormRules = {
name: { required: true, message: '请输入模型名称', trigger: 'blur' },
model: { required: true, message: '请输入模型标识', trigger: 'blur' },
platform: { required: true, message: '请选择平台', trigger: 'change' },
keyId: { required: true, message: '请选择 API Key', trigger: 'change' }
}
const apiKeyColumns: DataTableColumns<ApiKeyItem> = [
{ title: '名称', key: 'name', width: 150 },
{ title: '平台', key: 'platform', width: 120 },
{
title: 'API Key',
key: 'apiKey',
ellipsis: { tooltip: true },
render: (row) => {
const key = row.apiKey || ''
return key.length > 20 ? `${key.substring(0, 20)}...` : key
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) =>
h(NTag, { type: row.status === 0 ? 'success' : 'error' }, () => (row.status === 0 ? '启用' : '禁用'))
},
{
title: '公开状态',
key: 'publicStatus',
width: 100,
render: (row) =>
h(NTag, { type: row.publicStatus ? 'info' : 'warning' }, () => (row.publicStatus ? '公开' : '私有'))
},
{
title: '操作',
key: 'actions',
width: 150,
render: (row) =>
h('div', { class: 'flex gap-2' }, [
h(
NButton,
{
size: 'small',
onClick: () => handleEditApiKey(row)
},
() => '编辑'
),
h(
NButton,
{
size: 'small',
type: 'error',
onClick: () => handleDeleteApiKey(row)
},
() => '删除'
)
])
}
]
const modelColumns: DataTableColumns<ModelItem> = [
{ title: '模型名称', key: 'name', width: 150 },
{ title: '模型标识', key: 'model', width: 180 },
{ title: '平台', key: 'platform', width: 120 },
{
title: '类型',
key: 'type',
width: 100,
render: (row) => {
const typeMap: Record<number, string> = { 1: '聊天', 2: '图片', 3: '音乐' }
return typeMap[row.type] || '未知'
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) =>
h(NTag, { type: row.status === 0 ? 'success' : 'error' }, () => (row.status === 0 ? '启用' : '禁用'))
},
{
title: '公开状态',
key: 'publicStatus',
width: 100,
render: (row) =>
h(NTag, { type: row.publicStatus === 0 ? 'info' : 'warning' }, () =>
row.publicStatus === 0 ? '公开' : '私有'
)
},
{
title: '操作',
key: 'actions',
width: 150,
render: (row) =>
h('div', { class: 'flex gap-2' }, [
h(
NButton,
{
size: 'small',
onClick: () => handleEditModel(row)
},
() => '编辑'
),
h(
NButton,
{
size: 'small',
type: 'error',
onClick: () => handleDeleteModel(row)
},
() => '删除'
)
])
}
]
const loadApiKeyList = async () => {
apiKeyLoading.value = true
try {
const keyword = apiKeySearchForm.value.keyword.trim()
const data = await getApiKeyAdminPage({
pageNo: apiKeyPagination.value.page,
pageSize: apiKeyPagination.value.pageSize,
name: keyword || undefined
})
apiKeyList.value = data.list || []
apiKeyPagination.value.itemCount = data.total || 0
} catch (error) {
console.error('加载 API Key 列表失败:', error)
message.error('加载 API Key 列表失败')
} finally {
apiKeyLoading.value = false
}
}
const loadModelList = async () => {
modelLoading.value = true
try {
const keyword = modelSearchForm.value.keyword.trim()
const data = await getModelAdminPage({
pageNo: modelPagination.value.page,
pageSize: modelPagination.value.pageSize,
name: keyword || undefined
})
modelList.value = data.list || []
modelPagination.value.itemCount = data.total || 0
} catch (error) {
console.error('加载模型列表失败:', error)
message.error('加载模型列表失败')
} finally {
modelLoading.value = false
}
}
const handleApiKeySearch = () => {
apiKeyPagination.value.page = 1
loadApiKeyList()
}
const handleApiKeyReset = () => {
apiKeySearchForm.value.keyword = ''
apiKeyPagination.value.page = 1
loadApiKeyList()
}
const handleModelSearch = () => {
modelPagination.value.page = 1
loadModelList()
}
const handleModelReset = () => {
modelSearchForm.value.keyword = ''
modelPagination.value.page = 1
loadModelList()
}
// 添加 API Key
const handleAddApiKey = () => {
apiKeyForm.value = {
id: undefined,
name: '',
platform: '',
apiKey: '',
url: '',
status: 0,
publicStatus: false
}
showApiKeyModal.value = true
}
// 编辑 API Key
const handleEditApiKey = (row: any) => {
apiKeyForm.value = { ...row }
showApiKeyModal.value = true
}
const handleSaveApiKey = async () => {
try {
await apiKeyFormRef.value?.validate()
apiKeySaving.value = true
if (apiKeyForm.value.id) {
await updateApiKeyAdmin(apiKeyForm.value as ApiKeyItem)
} else {
await createApiKey(apiKeyForm.value as ApiKeyItem)
}
message.success('保存成功')
showApiKeyModal.value = false
await loadApiKeyList()
} catch (error) {
console.error('保存 API Key 失败:', error)
if (error !== false) {
message.error('保存失败')
}
} finally {
apiKeySaving.value = false
}
}
const handleDeleteApiKey = (row: ApiKeyItem) => {
dialog.warning({
title: '确认删除',
content: `确定要删除 API Key "${row.name}" 吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteApiKeyAdmin(row.id!)
message.success('删除成功')
await loadApiKeyList()
} catch (error) {
console.error('删除 API Key 失败:', error)
message.error('删除失败')
}
}
})
}
const handleAddModel = async () => {
await loadApiKeySimpleList()
modelForm.value = {
id: undefined,
name: '',
model: '',
platform: '',
keyId: undefined,
type: 1,
status: 0,
sort: 0,
publicStatus: 0
}
showModelModal.value = true
}
const handleEditModel = async (row: ModelItem) => {
await loadApiKeySimpleList()
modelForm.value = {
id: row.id,
name: row.name,
model: row.model,
platform: row.platform,
keyId: row.keyId,
type: row.type,
status: row.status,
sort: row.sort || 0,
publicStatus: row.publicStatus || 0
}
showModelModal.value = true
}
const handleSaveModel = async () => {
try {
await modelFormRef.value?.validate()
modelSaving.value = true
if (modelForm.value.id) {
await updateModelAdmin(modelForm.value as ModelItem)
} else {
await createModel(modelForm.value as ModelItem)
}
message.success('保存成功')
showModelModal.value = false
await loadModelList()
} catch (error) {
console.error('保存模型失败:', error)
if (error !== false) {
message.error('保存失败')
}
} finally {
modelSaving.value = false
}
}
const handleDeleteModel = (row: ModelItem) => {
dialog.warning({
title: '确认删除',
content: `确定要删除模型 "${row.name}" 吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteModelAdmin(row.id!)
message.success('删除成功')
await loadModelList()
} catch (error) {
console.error('删除模型失败:', error)
message.error('删除失败')
}
}
})
}
onMounted(() => {
loadPlatformList()
loadApiKeySimpleList()
loadApiKeyList()
loadModelList()
})
</script>

View File

@@ -1,5 +1,238 @@
<template>
<div>主页</div>
<div class="p-4 space-y-4">
<!-- 顶部概览统计 -->
<n-grid :cols="4" x-gap="16" y-gap="16">
<n-grid-item v-for="card in overviewCards" :key="card.key">
<n-card :bordered="false" size="small">
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-gray-500">{{ card.label }}</div>
<div class="mt-2 text-2xl font-semibold">
{{ card.value }}
</div>
</div>
<n-icon :component="card.icon" :size="32" class="text-primary" />
</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 快捷入口 -->
<n-card title="快捷入口" :bordered="false" size="small">
<n-grid :cols="4" x-gap="16" y-gap="16">
<n-grid-item v-for="entry in quickEntries" :key="entry.key">
<n-card
class="cursor-pointer hover:shadow-sm transition-shadow"
size="small"
@click="go(entry.path)">
<div class="flex items-center gap-3">
<n-icon :component="entry.icon" :size="26" />
<div>
<div class="font-medium">{{ entry.label }}</div>
<div class="mt-1 text-xs text-gray-500">{{ entry.desc }}</div>
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
</n-card>
<!-- 风控与告警一览 -->
<n-grid :cols="2" x-gap="16" y-gap="16">
<n-grid-item>
<n-card title="风控 / 黑名单概览" :bordered="false" size="small">
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">今日新增黑名单</span>
<span class="text-lg font-semibold text-red-500">{{ blackStats.todayNew }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">本周新增黑名单</span>
<span class="text-lg font-semibold">{{ blackStats.weekNew }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-sm text-gray-600">黑名单总数</span>
<span class="text-lg font-semibold">{{ blackStats.total }}</span>
</div>
<div class="mt-4">
<n-button type="primary" size="small" @click="go('/im/black')">查看详情</n-button>
</div>
</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="AI 使用概览" :bordered="false" size="small">
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">今日调用次数</span>
<span class="text-lg font-semibold text-blue-500">{{ aiStats.todayCalls }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">本周调用次数</span>
<span class="text-lg font-semibold">{{ aiStats.weekCalls }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-sm text-gray-600">活跃模型数</span>
<span class="text-lg font-semibold">{{ aiStats.activeModels }}</span>
</div>
<div class="mt-4">
<n-button type="primary" size="small" @click="go('/ai/model')">查看详情</n-button>
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { SmartHome, Message2, BrowserX, Settings, Users, Robot } from '@vicons/tabler'
import { getHomeStats } from '@/api/admin'
const router = useRouter()
// 统计数据
const stats = ref({
todayActiveUser: 0,
totalGroup: 0,
blackCount: 0,
aiCallToday: 0
})
// 黑名单统计
const blackStats = ref({
todayNew: 0,
weekNew: 0,
total: 0
})
// AI 统计
const aiStats = ref({
todayCalls: 0,
weekCalls: 0,
activeModels: 0
})
// 加载统计数据
const loadStats = async () => {
try {
const response = await getHomeStats()
// 更新顶部统计数据
stats.value = {
todayActiveUser: response.todayActiveUser,
totalGroup: response.totalGroup,
blackCount: response.blackCount,
aiCallToday: response.aiCallToday
}
// 更新黑名单统计
blackStats.value = {
todayNew: response.blackStats.todayNew,
weekNew: response.blackStats.weekNew,
total: response.blackStats.total
}
// 更新 AI 统计
aiStats.value = {
todayCalls: response.aiStats.todayCalls,
weekCalls: response.aiStats.weekCalls,
activeModels: response.aiStats.activeModels
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const overviewCards = computed(() => [
{
key: 'todayActiveUser',
label: '今日活跃用户',
value: stats.value.todayActiveUser || 0,
icon: Users
},
{
key: 'totalGroup',
label: '群聊总数',
value: stats.value.totalGroup || 0,
icon: Message2
},
{
key: 'blackCount',
label: '当前黑名单数',
value: stats.value.blackCount || 0,
icon: BrowserX
},
{
key: 'aiCallToday',
label: '今日 AI 调用',
value: stats.value.aiCallToday || 0,
icon: Robot
}
])
const quickEntries = computed(() => [
{
key: 'user',
label: '用户 / 黑名单管理',
desc: '管理用户、拉黑 UID / IP风控恶意账号',
icon: SmartHome,
path: '/im/user'
},
{
key: 'black',
label: '黑名单列表',
desc: '查看并维护 im_black 黑名单记录',
icon: BrowserX,
path: '/im/black'
},
{
key: 'group',
label: '群聊管理',
desc: '管理 im_room_group、群成员、群公告、群管理员',
icon: Message2,
path: '/im/group'
},
{
key: 'moment',
label: '朋友圈管理',
desc: '审核动态内容,处理违规内容与举报',
icon: Message2,
path: '/im/moment'
},
{
key: 'ai',
label: 'AI 能力中心',
desc: '配置 AI 平台与模型,查看调用情况',
icon: SmartHome,
path: '/ai/model'
},
{
key: 'config',
label: 'IM 配置',
desc: 'im_config 配置、系统参数与高级设置',
icon: Settings,
path: '/im/config'
},
{
key: 'friend',
label: '联系人 / 好友管理',
desc: '联系人、好友关系、申请记录等',
icon: BrowserX,
path: '/im/contact'
}
])
const go = (path: string) => {
if (!path) return
router.push(path)
}
onMounted(() => {
loadStats()
})
</script>
<style scoped></style>

645
src/views/page/ImBlack.vue Normal file
View File

@@ -0,0 +1,645 @@
<template>
<div class="p-4 space-y-4">
<n-card title="黑名单列表">
<template #header-extra>
<n-button type="primary" @click="openAddModal">
拉黑用户
</n-button>
</template>
<!-- 查询条件 -->
<div class="mb-4">
<n-form inline :model="query">
<n-form-item label="类型">
<n-select
v-model:value="query.type"
:options="typeOptions"
class="w-52"
clearable
placeholder="全部"
/>
</n-form-item>
<n-form-item label="目标">
<n-input
v-model:value="query.target"
class="w-52"
clearable
placeholder="请输入 UID / IP"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleSearch">
查询
</n-button>
<n-button @click="handleReset">
重置
</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<!-- 黑名单表格 -->
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:bordered="false"
:row-key="rowKey"
:pagination="pagination"
:max-height="600"
/>
</n-card>
<n-modal
v-model:show="showAddModal"
preset="card"
title="拉黑用户"
:mask-closable="false"
:bordered="false"
:segmented="{ content: 'soft', footer: 'soft' }"
style="width: 520px; max-width: 100%"
>
<n-form
:model="blackForm"
label-placement="left"
label-width="88"
class="mt-2 space-y-2"
>
<n-form-item label="拉黑类型">
<n-select
v-model:value="blackForm.type"
:options="[
{ label: '用户UID', value: 2 },
{ label: 'IP地址', value: 1 }
]"
placeholder="选择拉黑类型"
@update:value="handleTypeChange"
/>
</n-form-item>
<n-form-item v-if="blackForm.type === 2" label="选择用户">
<div class="flex flex-col gap-1 w-full">
<n-select
v-model:value="blackForm.target"
:options="userOptions"
:loading="userSearchLoading"
filterable
remote
clearable
placeholder="搜索用户(昵称/UID"
:consistent-menu-width="false"
@search="handleUserSearch"
/>
<span class="text-xs text-gray-500">搜索并选择要拉黑的用户</span>
</div>
</n-form-item>
<n-form-item v-else label="IP地址">
<div class="flex flex-col gap-1 w-full">
<n-input
v-model:value="blackForm.target"
placeholder="请输入IP地址例如192.168.1.1"
clearable
/>
<span class="text-xs text-gray-500">请输入要拉黑的IP地址</span>
</div>
</n-form-item>
<n-form-item label="截止时间">
<div class="flex flex-col gap-1 w-full">
<div class="flex items-center gap-2 w-full">
<n-input-number
v-model:value="blackForm.deadline"
:min="0"
:max="525600"
class="flex-1"
>
<template #suffix>
<span class="text-xs text-gray-500">分钟</span>
</template>
</n-input-number>
</div>
<span class="text-xs text-gray-500">0 表示永久拉黑建议根据违规程度合理设置时长</span>
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-start gap-2">
<n-button type="primary" :loading="submitLoading" @click="handleBlack">
确认拉黑
</n-button>
<n-button @click="handleBlackCancel">
取消
</n-button>
</div>
</template>
</n-modal>
<!-- 编辑黑名单弹窗 -->
<n-modal
v-model:show="showEditModal"
preset="card"
title="编辑黑名单"
:mask-closable="false"
:bordered="false"
:segmented="{ content: 'soft', footer: 'soft' }"
style="width: 520px; max-width: 100%"
>
<n-form
:model="editForm"
label-placement="left"
label-width="88"
class="mt-2 space-y-2"
>
<n-form-item label="类型">
<n-select
v-model:value="editForm.type"
:options="[
{ label: 'UID', value: 2 },
{ label: 'IP', value: 1 }
]"
placeholder="选择类型"
/>
</n-form-item>
<n-form-item label="目标">
<div v-if="editForm.type === 2" class="flex flex-col gap-1 w-full">
<n-select
v-model:value="editForm.target"
:options="editUserOptions"
:loading="editUserSearchLoading"
filterable
remote
clearable
placeholder="搜索用户(昵称/UID"
:consistent-menu-width="false"
@search="handleEditUserSearch"
/>
<span class="text-xs text-gray-500">搜索并选择要拉黑的用户</span>
</div>
<n-input
v-else
v-model:value="editForm.target"
placeholder="请输入 IP"
clearable
/>
</n-form-item>
<n-form-item label="截止时间">
<div class="flex flex-col gap-1 w-full">
<n-date-picker
v-model:value="editForm.deadlineTimestamp"
type="datetime"
clearable
placeholder="选择截止时间"
class="w-full"
:is-date-disabled="(ts: number) => ts < Date.now()"
/>
<span class="text-xs text-gray-500">不选择表示永久拉黑。</span>
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-start gap-2">
<n-button type="primary" :loading="editLoading" @click="handleEditSave">
保存
</n-button>
<n-button @click="showEditModal = false">
取消
</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import type { DataTableColumns, PaginationProps, SelectOption } from 'naive-ui'
import { NButton } from 'naive-ui'
import { getImBlackPage, addImBlack, editImBlack, removeImBlack, type ImBlackItem } from '@/api/imBlack'
import { searchUserByNicknameApi } from '@/api/user'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref<ImBlackItem[]>([])
const showAddModal = ref(false)
const showEditModal = ref(false)
const editLoading = ref(false)
const pageNo = ref(1)
const pageSize = ref(10)
const total = ref(0)
const query = reactive<{
type: number
target: string
}>({
type: 0,
target: ''
})
const blackForm = reactive<{
type: number
target: string
deadline: number
}>({
type: 2, // 默认拉黑用户UID
target: '',
deadline: 60
})
const editForm = reactive<{
id: string
type: number
target: string
deadlineTimestamp: number | null
}>({
id: '',
type: 2,
target: '',
deadlineTimestamp: null
})
// 用户搜索相关
const userSearchLoading = ref(false)
const userOptions = ref<SelectOption[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
// 编辑弹窗的用户搜索相关
const editUserSearchLoading = ref(false)
const editUserOptions = ref<SelectOption[]>([])
let editSearchTimer: ReturnType<typeof setTimeout> | null = null
const typeOptions = [
{ label: '全部', value: 0 },
{ label: 'UID', value: 2 },
{ label: 'IP', value: 1 }
]
const columns: DataTableColumns<ImBlackItem> = [
{
title: '类型',
key: 'type',
width: 100,
render(row) {
if (row.type === 1) return 'IP'
if (row.type === 2) return 'UID'
return '-'
}
},
{
title: '目标',
key: 'target',
minWidth: 160
},
{
title: '用户昵称',
key: 'userName',
minWidth: 140,
render(row) {
if (row.type === 2 && row.userName) {
return row.userName
}
return '-'
}
},
{
title: '截止时间',
key: 'deadline',
minWidth: 180,
render(row) {
if (!row.deadline) return '永久'
const date = new Date(row.deadline)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
},
{
title: '创建时间',
key: 'createTime',
minWidth: 180,
render(row) {
if (!row.createTime) return '-'
const date = new Date(row.createTime)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
},
{
title: '操作',
key: 'actions',
width: 160,
render(row) {
return h(
'div',
{ class: 'flex gap-2' },
[
h(
NButton,
{
size: 'small',
quaternary: true,
type: 'primary',
onClick: () => openEditModal(row)
},
{ default: () => '编辑' }
),
h(
NButton,
{
size: 'small',
quaternary: true,
type: 'error',
onClick: () => handleRemoveBlack(row)
},
{ default: () => '移除' }
)
]
)
}
}
]
const rowKey = (row: ImBlackItem) => row.id
const pagination = computed<PaginationProps>(() => ({
page: pageNo.value,
pageSize: pageSize.value,
itemCount: total.value,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
pageNo.value = page
loadData()
},
onUpdatePageSize: (size: number) => {
pageSize.value = size
pageNo.value = 1
loadData()
}
}))
async function loadData() {
loading.value = true
try {
const res = await getImBlackPage({
pageNo: pageNo.value,
pageSize: pageSize.value,
type: query.type === 0 ? undefined : query.type,
target: query.target || undefined
})
tableData.value = res.list || []
total.value = res.totalRecords || 0
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '获取黑名单列表失败'
window.$message?.error(msg)
} finally {
loading.value = false
}
}
function handleSearch() {
pageNo.value = 1
loadData()
}
function handleReset() {
query.type = 0
query.target = ''
handleSearch()
}
function openAddModal() {
handleBlackReset()
showAddModal.value = true
}
function handleBlackCancel() {
showAddModal.value = false
}
async function handleBlack() {
if (!blackForm.target) {
const msg = blackForm.type === 2 ? '请选择要拉黑的用户' : '请输入要拉黑的IP地址'
window.$message?.warning(msg)
return
}
if (blackForm.deadline == null || Number.isNaN(blackForm.deadline) || blackForm.deadline < 0) {
window.$message?.warning('请输入正确的截止时间(分钟)')
return
}
submitLoading.value = true
try {
await addImBlack({
type: blackForm.type,
target: blackForm.target,
deadline: blackForm.deadline
})
window.$message?.success('拉黑成功')
// 拉黑后刷新列表
handleBlackCancel()
pageNo.value = 1
await loadData()
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '拉黑失败'
window.$message?.error(msg)
} finally {
submitLoading.value = false
}
}
function handleBlackReset() {
blackForm.type = 2
blackForm.target = ''
blackForm.deadline = 60
userOptions.value = []
}
// 类型切换时清空目标
function handleTypeChange() {
blackForm.target = ''
userOptions.value = []
}
// 打开编辑弹窗
async function openEditModal(row: ImBlackItem) {
editForm.id = row.id
editForm.type = row.type
editForm.target = row.target
// 设置截止时间
if (row.deadline) {
editForm.deadlineTimestamp = new Date(row.deadline).getTime()
} else {
editForm.deadlineTimestamp = null
}
// 如果是UID类型编辑时仅从黑名单分页查询回显
if (row.type === 2) {
try {
const res = await getImBlackPage({
pageNo: 1,
pageSize: 20,
target: row.target
})
editUserOptions.value = (res.list || []).map((item) => ({
label: `${item.userName || item.target} (${item.target})`,
value: item.target || ''
}))
} catch (error) {
console.error('加载黑名单信息失败:', error)
editUserOptions.value = []
}
}
showEditModal.value = true
}
// 保存编辑
async function handleEditSave() {
if (!editForm.target) {
window.$message?.warning('请输入目标')
return
}
editLoading.value = true
try {
// 将时间戳转换为标准格式字符串
let deadlineStr = ''
if (editForm.deadlineTimestamp) {
const date = new Date(editForm.deadlineTimestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
deadlineStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
await editImBlack({
id: editForm.id,
type: editForm.type,
target: editForm.target,
deadline: deadlineStr
})
window.$message?.success('编辑成功')
showEditModal.value = false
editUserOptions.value = []
await loadData()
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '编辑失败'
window.$message?.error(msg)
} finally {
editLoading.value = false
}
}
// 编辑弹窗的用户搜索
async function handleEditUserSearch(keyword: string) {
if (editSearchTimer) {
clearTimeout(editSearchTimer)
}
if (!keyword || keyword.trim() === '') {
editUserOptions.value = []
return
}
editSearchTimer = setTimeout(async () => {
editUserSearchLoading.value = true
try {
const res = await getImBlackPage({
pageNo: 1,
pageSize: 20,
target: keyword.trim()
})
editUserOptions.value = (res.list || []).map((item) => ({
label: `${item.userName || item.target} (UID: ${item.target})`,
value: String(item.target || '')
}))
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '搜索黑名单失败'
window.$message?.error(msg)
editUserOptions.value = []
} finally {
editUserSearchLoading.value = false
}
}, 300)
}
// 编辑弹窗的用户下拉框获得焦点时加载默认列表
// 编辑弹窗的用户选择框获得焦点时不做任何操作
// 用户需要输入关键词才会搜索
// 移除黑名单
async function handleRemoveBlack(row: ImBlackItem) {
window.$dialog?.warning({
title: '确认移除',
content: `确定要将 ${row.target} 从黑名单中移除吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
await removeImBlack({ id: row.id })
window.$message?.success('移除成功')
await loadData()
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '移除失败'
window.$message?.error(msg)
}
}
})
}
// 用户搜索处理
async function handleUserSearch(query: string) {
if (searchTimer) {
clearTimeout(searchTimer)
}
if (!query || query.trim() === '') {
// 如果没有搜索词,清空选项列表
userOptions.value = []
return
}
searchTimer = setTimeout(async () => {
userSearchLoading.value = true
try {
const res = await searchUserByNicknameApi({
pageNo: 1,
pageSize: 20,
keyword: query.trim()
})
userOptions.value = (res.list || []).map((user) => ({
label: `${user.name || user.account || ''} (UID: ${user.uid})`,
value: String(user.uid || '')
}))
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '搜索用户失败'
window.$message?.error(msg)
userOptions.value = []
} finally {
userSearchLoading.value = false
}
}, 300)
}
// 新增弹窗的用户选择框获得焦点时不做任何操作
// 用户需要输入关键词才会搜索
onMounted(() => {
loadData()
})
</script>

387
src/views/page/ImConfig.vue Normal file
View File

@@ -0,0 +1,387 @@
<template>
<div class="p-4">
<n-card title="IM 配置">
<n-tabs type="line" animated>
<!-- 系统配置 -->
<n-tab-pane name="system" tab="系统配置">
<div class="py-4">
<div class="mb-4 flex justify-end">
<n-button type="primary" @click="handleSaveSystemConfig" :loading="saving">
<template #icon>
<n-icon><SaveOutline /></n-icon>
</template>
保存配置
</n-button>
</div>
<n-form
ref="systemFormRef"
:model="systemConfig"
label-placement="left"
label-width="150px"
class="max-w-4xl">
<n-divider title-placement="left">基础配置</n-divider>
<n-form-item label="系统名称" path="systemName">
<n-input v-model:value="systemConfig.systemName" placeholder="请输入系统名称" />
</n-form-item>
<n-form-item label="系统 Logo" path="logo">
<n-input v-model:value="systemConfig.logo" placeholder="请输入 Logo URL" />
</n-form-item>
<n-form-item label="大群 ID" path="roomGroupId">
<n-input v-model:value="systemConfig.roomGroupId" placeholder="请输入大群 ID" />
</n-form-item>
<n-divider title-placement="left">七牛云配置</n-divider>
<n-form-item label="七牛云 CDN 域名" path="qnStorageCDN">
<n-input v-model:value="systemConfig.qnStorageCDN" placeholder="https://cdn.example.com" />
</n-form-item>
<n-form-item label="分片大小" path="fragmentSize">
<n-input v-model:value="systemConfig.fragmentSize" placeholder="分片大小MB" />
</n-form-item>
<n-form-item label="转存大小阈值" path="turnSharSize">
<n-input v-model:value="systemConfig.turnSharSize" placeholder="转存大小阈值MB" />
</n-form-item>
</n-form>
</div>
</n-tab-pane>
<!-- 配置列表 -->
<n-tab-pane name="list" tab="所有配置项">
<div class="py-4">
<div class="mb-4">
<n-form inline :model="searchForm">
<n-form-item label="配置键/名称">
<n-input
v-model:value="searchKey"
class="w-52"
clearable
placeholder="请输入配置键或名称" />
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleSearch">
查询
</n-button>
<n-button @click="handleResetSearch">
重置
</n-button>
<n-button type="primary" @click="handleAddConfig">
<template #icon>
<n-icon><AddOutline /></n-icon>
</template>
添加配置
</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<n-data-table
:columns="configColumns"
:data="displayConfigList"
:loading="loading"
:pagination="pagination"
:bordered="false"
striped />
</div>
</n-tab-pane>
</n-tabs>
</n-card>
<!-- 配置编辑弹窗 -->
<n-modal
v-model:show="showConfigModal"
preset="card"
:title="configModalTitle"
style="width: 600px"
:mask-closable="false">
<n-form
ref="configFormRef"
:model="configForm"
:rules="configRules"
label-placement="left"
label-width="100px">
<n-form-item label="配置类型" path="type">
<n-input v-model:value="configForm.type" placeholder="如: system, qiniu_up_config" />
</n-form-item>
<n-form-item label="配置名称" path="configName">
<n-input v-model:value="configForm.configName" placeholder="请输入配置名称" />
</n-form-item>
<n-form-item label="配置键" path="configKey">
<n-input
v-model:value="configForm.configKey"
placeholder="如: systemName, logo"
:disabled="!!configForm.id" />
</n-form-item>
<n-form-item label="配置值" path="configValue">
<n-input
v-model:value="configForm.configValue"
type="textarea"
placeholder="请输入配置值"
:rows="5" />
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="showConfigModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveConfig" :loading="configSaving">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, h, onMounted, computed } from 'vue'
import { NButton, useMessage, type DataTableColumns } from 'naive-ui'
import { SaveOutline, AddOutline } from '@vicons/ionicons5'
import { getSystemInit, getConfigList, batchUpdateConfig, updateConfig, type SysConfigItem } from '@/api/sysConfig'
const message = useMessage()
// 系统配置表单
const systemFormRef = ref()
const saving = ref(false)
const systemConfig = ref({
systemName: '',
logo: '',
roomGroupId: '',
qnStorageCDN: '',
fragmentSize: '',
turnSharSize: ''
})
// 配置映射用于保存时找到对应的配置ID
const configMap = ref<Map<string, SysConfigItem>>(new Map())
// 配置列表
const configList = ref<SysConfigItem[]>([])
const loading = ref(false)
const searchKey = ref('')
const searchForm = ref({
searchKey: ''
})
// 配置编辑
const showConfigModal = ref(false)
const configFormRef = ref()
const configSaving = ref(false)
const configForm = ref<Partial<SysConfigItem>>({
id: undefined,
type: '',
configName: '',
configKey: '',
configValue: ''
})
const configModalTitle = computed(() => (configForm.value.id ? '编辑配置' : '添加配置'))
const configRules = {
type: { required: true, message: '请输入配置类型', trigger: 'blur' },
configName: { required: true, message: '请输入配置名称', trigger: 'blur' },
configKey: { required: true, message: '请输入配置键', trigger: 'blur' },
configValue: { required: true, message: '请输入配置值', trigger: 'blur' }
}
// 搜索过滤后的配置列表
const displayConfigList = computed(() => {
if (!searchKey.value) return configList.value
const key = searchKey.value.toLowerCase()
return configList.value.filter(
(item) =>
item.configKey?.toLowerCase().includes(key) || item.configName?.toLowerCase().includes(key)
)
})
const pagination = ref({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onChange: (page: number) => {
pagination.value.page = page
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
}
})
// 配置列表表格列
const configColumns: DataTableColumns<SysConfigItem> = [
{ title: '类型', key: 'type', width: 150 },
{ title: '配置名称', key: 'configName', width: 200, ellipsis: { tooltip: true } },
{ title: '配置键', key: 'configKey', width: 200, ellipsis: { tooltip: true } },
{
title: '配置值',
key: 'configValue',
ellipsis: { tooltip: true },
render: (row: SysConfigItem) => {
const value = row.configValue || ''
return value.length > 50 ? `${value.substring(0, 50)}...` : value
}
},
{
title: '操作',
key: 'actions',
width: 100,
render: (row: SysConfigItem) =>
h(
NButton,
{
size: 'small',
onClick: () => handleEditConfig(row)
},
() => '编辑'
)
}
]
// 加载系统配置
const loadSystemConfig = async () => {
try {
const data = await getSystemInit()
systemConfig.value = {
systemName: data.name || '',
logo: data.logo || '',
roomGroupId: data.roomGroupId || '',
qnStorageCDN: data.qiNiu?.ossDomain || '',
fragmentSize: data.qiNiu?.fragmentSize || '',
turnSharSize: data.qiNiu?.turnSharSize || ''
}
} catch (error) {
console.error('加载系统配置失败:', error)
message.error('加载系统配置失败')
}
}
// 加载配置列表
const loadConfigList = async () => {
loading.value = true
try {
const data = await getConfigList()
configList.value = data || []
// 构建配置映射
configMap.value.clear()
data.forEach((item) => {
configMap.value.set(item.configKey, item)
})
message.success('配置列表加载成功')
} catch (error) {
console.error('加载配置列表失败:', error)
message.error('加载配置列表失败')
} finally {
loading.value = false
}
}
// 搜索处理(包含刷新功能)
const handleSearch = async () => {
pagination.value.page = 1
await loadConfigList()
}
// 重置搜索
const handleResetSearch = () => {
searchKey.value = ''
pagination.value.page = 1
}
// 保存系统配置
const handleSaveSystemConfig = async () => {
saving.value = true
try {
const updates: Partial<SysConfigItem>[] = []
// 构建需要更新的配置项
const configKeys = [
{ key: 'systemName', value: systemConfig.value.systemName, type: 'system' },
{ key: 'logo', value: systemConfig.value.logo, type: 'system' },
{ key: 'roomGroupId', value: systemConfig.value.roomGroupId, type: 'system' },
{ key: 'qnStorageCDN', value: systemConfig.value.qnStorageCDN, type: 'qiniu_up_config' },
{ key: 'fragmentSize', value: systemConfig.value.fragmentSize, type: 'qiniu_up_config' },
{ key: 'turnSharSize', value: systemConfig.value.turnSharSize, type: 'qiniu_up_config' }
]
configKeys.forEach(({ key, value, type }) => {
const existingConfig = configMap.value.get(key)
if (existingConfig) {
updates.push({
id: existingConfig.id,
configKey: key,
configValue: value,
type
})
}
})
if (updates.length > 0) {
await batchUpdateConfig(updates)
message.success('保存成功')
await loadConfigList()
await loadSystemConfig()
} else {
message.warning('没有需要保存的配置')
}
} catch (error) {
console.error('保存配置失败:', error)
message.error('保存配置失败')
} finally {
saving.value = false
}
}
// 添加配置
const handleAddConfig = () => {
configForm.value = {
id: undefined,
type: '',
configName: '',
configKey: '',
configValue: ''
}
showConfigModal.value = true
}
// 编辑配置
const handleEditConfig = (row: SysConfigItem) => {
configForm.value = { ...row }
showConfigModal.value = true
}
// 保存配置
const handleSaveConfig = async () => {
try {
await configFormRef.value?.validate()
configSaving.value = true
await updateConfig(configForm.value)
message.success('保存成功')
showConfigModal.value = false
await loadConfigList()
await loadSystemConfig()
} catch (error) {
console.error('保存配置失败:', error)
if (error !== false) {
// 不是表单验证错误
message.error('保存配置失败')
}
} finally {
configSaving.value = false
}
}
// 初始化
onMounted(async () => {
await loadConfigList()
await loadSystemConfig()
})
</script>

View File

@@ -0,0 +1,253 @@
<template>
<div class="contact-page">
<n-card title="联系人 / 好友管理" :bordered="false">
<!-- 搜索栏 -->
<div class="search-bar">
<n-input
v-model:value="searchKeyword"
placeholder="搜索用户名、昵称、手机号..."
clearable
@keyup.enter="handleSearch">
<template #prefix>
<span style="font-size: 16px">🔍</span>
</template>
</n-input>
<n-button type="primary" @click="handleSearch">搜索</n-button>
<n-button @click="handleReset">重置</n-button>
</div>
<!-- 用户列表 -->
<n-data-table
ref="tableRef"
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
:row-key="(row: ImUser) => row.uid"
class="user-table" />
</n-card>
<!-- 好友列表弹窗 -->
<FriendListModal ref="friendListModalRef" />
</div>
</template>
<script setup lang="ts">
import { ref, h, onMounted } from 'vue'
import { NButton, NAvatar, NSpace, useMessage, type DataTableColumns } from 'naive-ui'
import { searchImUser, type ImUser } from '@/api/imUser'
import { AvatarUtils } from '@/utils/avatar'
import paging from '@/hooks/usePaging'
import FriendListModal from '@/views/composables/modal/friendListModal/index.vue'
const message = useMessage()
const { pageNum, pageSize } = paging
// 数据
const searchKeyword = ref('')
const tableData = ref<ImUser[]>([])
const loading = ref(false)
const total = ref(0)
const friendListModalRef = ref<any>(null)
// 分页配置
const pagination = {
page: pageNum.value,
pageSize: pageSize.value,
showSizePicker: true,
pageSizes: [10, 20, 30, 50],
onChange: (page: number) => {
pageNum.value = page
loadUserList()
},
onUpdatePageSize: (size: number) => {
pageSize.value = size
pageNum.value = 1
loadUserList()
},
get pageCount() {
return Math.ceil(total.value / pageSize.value)
}
}
// 表格列配置
const columns: DataTableColumns<ImUser> = [
{
title: '用户',
key: 'name',
width: 250,
render: (row) => {
return h(
NSpace,
{ justify: 'start', align: 'center' },
{
default: () => [
h(NAvatar, {
size: 'large',
round: true,
src: AvatarUtils.getAvatarUrl(row.avatar),
fallbackSrc: '/logoD.png'
}),
h(
NSpace,
{ vertical: true, size: 5 },
{
default: () => [
h(
'p',
{
style: {
fontWeight: 'bold',
padding: 0,
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '150px'
}
},
row.name || row.account
),
h(
'p',
{
style: {
color: '#ccc',
fontSize: '12px',
padding: 0,
margin: 0
}
},
row.account
)
]
}
)
]
}
)
}
},
{
title: 'UID',
key: 'uid',
width: 150,
ellipsis: {
tooltip: true
}
},
{
title: '账号',
key: 'account',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (row) => {
return h(
NButton,
{
size: 'small',
type: 'primary',
ghost: true,
onClick: () => handleViewFriends(row)
},
{
default: () => '查看好友'
}
)
}
}
]
// 加载IM用户列表
const loadUserList = async () => {
loading.value = true
try {
const data = await searchImUser({
pageNo: pageNum.value,
pageSize: pageSize.value,
keyword: searchKeyword.value || undefined
})
tableData.value = data.list || []
total.value = data.totalRecords || 0
pagination.page = pageNum.value
} catch (error) {
console.error('加载IM用户列表失败:', error)
message.error('加载IM用户列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pageNum.value = 1
loadUserList()
}
// 重置
const handleReset = () => {
searchKeyword.value = ''
pageNum.value = 1
loadUserList()
}
// 查看好友
const handleViewFriends = (user: ImUser) => {
if (!user.uid) {
message.warning('该用户没有UID,无法查看好友列表')
return
}
// 转换为 pageUser 格式
const pageUser = {
id: user.uid,
uid: user.uid,
userName: user.account,
nickName: user.name,
role: '',
status: 1,
email: '',
mobile: '',
avatar: user.avatar,
createTime: '',
updateTime: ''
}
friendListModalRef.value?.openModal(pageUser)
}
// 初始化
onMounted(() => {
loadUserList()
})
</script>
<style scoped lang="scss">
.contact-page {
padding: 16px;
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
:deep(.n-input) {
flex: 1;
max-width: 400px;
}
}
.user-table {
:deep(.n-data-table-th) {
font-weight: 600;
}
}
}
</style>

1015
src/views/page/ImGroup.vue Normal file

File diff suppressed because it is too large Load Diff

327
src/views/page/ImMoment.vue Normal file
View File

@@ -0,0 +1,327 @@
<template>
<div class="p-4 space-y-4">
<n-card title="朋友圈管理">
<div class="mb-4">
<n-form inline :model="query">
<n-form-item label="用户昵称">
<n-input
v-model:value="query.userName"
class="w-52"
clearable
placeholder="请输入用户昵称"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleSearch">
查询
</n-button>
<n-button @click="handleReset">
重置
</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:bordered="false"
:row-key="rowKey"
/>
<div class="mt-4 flex items-center justify-center">
<n-button v-if="!isLast" :loading="loading" @click="loadMore">
加载更多
</n-button>
<span v-else class="text-xs text-gray-400">已加载全部数据</span>
</div>
</n-card>
<n-modal
v-model:show="showCommentModal"
preset="card"
title="朋友圈评论"
:mask-closable="true"
:bordered="false"
:segmented="{ content: 'soft', footer: 'soft' }"
style="width: 880px; max-width: 100%"
>
<div class="space-y-4">
<div class="p-4 bg-gray-50 rounded">
<div class="flex items-center gap-3 mb-3">
<n-avatar
:src="currentFeed?.userAvatar"
:size="44"
round
:fallback-src="'https://api.dicebear.com/7.x/avataaars/svg?seed=' + currentFeed?.userName"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="text-sm font-medium truncate">{{ currentFeed?.userName }}</div>
<div class="text-xs text-gray-500">{{ currentFeed?.createTime }}</div>
</div>
<div class="text-sm text-gray-700 mt-2 break-words">{{ currentFeed?.content }}</div>
</div>
</div>
</div>
<div v-if="commentLoading" class="text-center py-8">
<n-spin size="medium" />
</div>
<div v-else-if="commentList.length === 0" class="text-center py-8 text-gray-400">
暂无评论
</div>
<div v-else class="space-y-3 max-h-[520px] overflow-y-auto">
<div
v-for="comment in commentList"
:key="comment.id"
class="p-3 bg-white border border-gray-200 rounded hover:bg-gray-50 transition-colors"
>
<div class="flex items-start gap-3">
<n-avatar
:src="comment.userAvatar"
:size="38"
round
:fallback-src="'https://api.dicebear.com/7.x/avataaars/svg?seed=' + comment.userName"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<div class="text-sm font-medium truncate">{{ comment.userName }}</div>
<div class="text-xs text-gray-500 truncate">UID: {{ comment.uid }}</div>
</div>
<div class="flex items-center gap-2">
<div class="text-xs text-gray-400">{{ comment.createTime }}</div>
<n-popconfirm @positive-click="handleDeleteComment(comment.id)">
<template #trigger>
<n-button size="tiny" type="error" quaternary>
删除
</n-button>
</template>
确定删除该评论吗?
</n-popconfirm>
</div>
</div>
<div v-if="comment.replyUserName" class="text-xs text-gray-500 mt-1">
回复 @{{ comment.replyUserName }}
</div>
<div class="text-sm text-gray-700 mt-2 break-words">{{ comment.content }}</div>
</div>
</div>
</div>
</div>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NSpace } from 'naive-ui'
import {
deleteImFeed,
deleteImFeedComment,
getImFeedComments,
getImFeedList,
type ImFeedItem
} from '@/api/imFeed'
const loading = ref(false)
const tableData = ref<ImFeedItem[]>([])
const cursor = ref<string | null>(null)
const isLast = ref(false)
const pageSize = ref(10)
const query = ref({
userName: ''
})
const showCommentModal = ref(false)
const commentLoading = ref(false)
const commentList = ref<any[]>([])
const currentFeed = ref<ImFeedItem | null>(null)
const columns: DataTableColumns<ImFeedItem> = [
{
title: '用户昵称',
key: 'userName',
width: 200
},
{
title: '内容',
key: 'content',
minWidth: 240
},
{
title: '媒体类型',
key: 'mediaType',
width: 100,
render(row) {
if (row.mediaType === 1) return '图片'
if (row.mediaType === 2) return '视频'
return '文本'
}
},
{
title: '权限',
key: 'permission',
width: 140,
render(row) {
const map: Record<string, string> = {
privacy: '仅自己可见',
open: '公开',
partVisible: '部分可见',
notAnyone: '不给谁看'
}
return map[row.permission] || row.permission || '-'
}
},
{
title: '点赞数',
key: 'likeCount',
width: 80
},
{
title: '评论数',
key: 'commentCount',
width: 80
},
{
title: '操作',
key: 'actions',
width: 180,
render(row) {
return h(
NSpace,
{ size: 'small' },
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'primary',
quaternary: true,
onClick: () => handleViewComments(row)
},
{ default: () => '查看评论' }
),
h(
NButton,
{
size: 'small',
type: 'error',
quaternary: true,
onClick: () => handleDelete(row)
},
{ default: () => '删除' }
)
]
}
)
}
}
]
const rowKey = (row: ImFeedItem) => `${row.id}`
async function fetchList(reset = false) {
if (loading.value) return
loading.value = true
try {
const res = await getImFeedList({
pageSize: pageSize.value,
cursor: reset ? undefined : cursor.value || undefined,
userName: query.value.userName.trim() || undefined
})
cursor.value = res.cursor
isLast.value = !!res.isLast
const list = res.list || []
tableData.value = reset ? list : [...tableData.value, ...list]
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '获取朋友圈列表失败'
window.$message?.error(msg)
} finally {
loading.value = false
}
}
function handleSearch() {
cursor.value = null
isLast.value = false
fetchList(true)
}
function handleReset() {
query.value.userName = ''
cursor.value = null
isLast.value = false
fetchList(true)
}
function loadMore() {
if (isLast.value) return
fetchList(false)
}
async function handleViewComments(row: ImFeedItem) {
if (!row.id) return
currentFeed.value = row
showCommentModal.value = true
commentLoading.value = true
commentList.value = []
try {
const res = await getImFeedComments(row.id)
commentList.value = res || []
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '获取评论列表失败'
window.$message?.error(msg)
} finally {
commentLoading.value = false
}
}
async function handleDeleteComment(commentId: string) {
try {
await deleteImFeedComment(commentId)
window.$message?.success('删除成功')
commentList.value = commentList.value.filter(c => c.id !== commentId)
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '删除评论失败'
window.$message?.error(msg)
}
}
async function handleDelete(row: ImFeedItem) {
if (!row.id) return
const ok = window.confirm('确定删除该朋友圈吗?')
if (!ok) return
try {
loading.value = true
await deleteImFeed(row.id)
window.$message?.success('删除成功')
// 删除后重新加载列表,从第一页开始
cursor.value = null
isLast.value = false
await fetchList(true)
} catch (error: any) {
const msg = (error && (error.msg || error.message)) || '删除朋友圈失败'
window.$message?.error(msg)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchList(true)
})
</script>

13
src/views/page/ImUser.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<div class="p-4 space-y-4">
<n-card title="用户 / 黑名单管理">
<div class="text-sm text-gray-500">
这里是 IM 用户 / 黑名单管理页面占位内容后续可以接入用户列表黑名单im_black等功能
</div>
</n-card>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="p-4 space-y-4">
<n-card :title="pageTitle">
<p class="mb-2 text-xs text-gray-500">
当前路由{{ route.path }}
</p>
<n-alert type="info" title="消息相关页面">
这里是消息相关页面的占位内容后续可以在这里接入消息列表通知站内信等功能
</n-alert>
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const pageTitle = computed(() => (route.meta.title as string) || '消息中心')
</script>

View File

@@ -46,6 +46,9 @@
<!--添加弹出框-->
<UserModal :title="title" />
<!--用户画像弹窗-->
<UserProfileModal :ref="(el: any) => userProfileModalRef = el" @edit="handleEditFromProfile" />
</template>
<script setup lang="ts">
@@ -58,14 +61,22 @@ import { i18n } from '@/i18n'
import { RotateClockwise2 } from '@vicons/tabler'
import userVar from '@/views/composables/drawer/userDrawer/userVar'
import { userTable } from '@/views/composables/table/userTable'
import UserProfileModal from '@/views/composables/modal/userProfileModal/index.vue'
const { t } = i18n.global
const { pageNum, pageSize } = paging
const loadingBarTargetRef = ref()
const title = ref('添加用户')
const { input } = userVar()
const { pagingLoad, tableData, loading, NoAccess } = useBase()
const { handleCheck, columns, statusColumn, pagination } = userTable(tableData)
const { pagingLoad, tableData, loading, NoAccess, showDrawer, contentData, rawData } = useBase()
const { handleCheck, columns, statusColumn, pagination, userProfileModalRef } = userTable(tableData)
// 从用户画像弹窗编辑
const handleEditFromProfile = (user: pageUser) => {
showDrawer.value = true
Object.assign(rawData.value, user)
Object.assign(contentData.value, user)
}
/**使用defineComponent重新构建组件*/
const LoadingBarTrigger = defineComponent({
@@ -129,4 +140,19 @@ const handleUpdateFilter = (filters: DataTableFilterState, sourceColumn: DataTab
<style lang="scss" scoped>
@use '@/styles/scss/user';
:deep(.user-avatar-wrapper) {
display: flex;
align-items: center;
cursor: pointer;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(1.05);
}
}
</style>

View File

@@ -1,7 +1,7 @@
import { ConfigEnv, defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite' //自动导入
import Components from 'unplugin-vue-components/vite' //组件注册
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression' //vite开启gzip压缩
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -105,6 +105,12 @@ export default defineConfig(({ mode }: ConfigEnv) => {
proxy:
config.VITE_HTTP_PROXY === 'Y'
? {
// api 统一代理到 gateway
'/api': {
target: config.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
// oauth 模块代理
'/proxy-oauth': {
target: config.VITE_API_BASE_URL,
@@ -129,6 +135,12 @@ export default defineConfig(({ mode }: ConfigEnv) => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/proxy-ai/, '/ai')
},
// im 模块代理
'/proxy-im': {
target: config.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/proxy-im/, '/im')
},
// gateway 模块代理
'/proxy-gateway': {
target: config.VITE_API_BASE_URL,