fix: connect to AI, Moments management, blocking, contacts, and system configuration
8259
pnpm-lock.yaml
generated
BIN
public/avatar/001.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/avatar/002.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/003.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/004.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/005.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/avatar/006.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/avatar/007.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/008.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/avatar/009.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/avatar/010.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/avatar/011.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/avatar/012.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/013.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/avatar/014.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/avatar/015.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/avatar/016.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/avatar/017.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/avatar/018.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/019.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/avatar/020.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/avatar/021.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/avatar/022.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/logoD.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/logoL.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
55
src/api/admin.ts
Normal 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
@@ -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
@@ -0,0 +1,113 @@
|
||||
import { request } from '@/utils/http'
|
||||
import { RequestModule } from '@/enums/request'
|
||||
|
||||
/** 黑名单记录 */
|
||||
export interface ImBlackItem {
|
||||
/** 主键 ID */
|
||||
id: string
|
||||
/** 类型:1=IP,2=UID */
|
||||
type: number
|
||||
/** 拉黑目标(IP 或 UID) */
|
||||
target: string
|
||||
/** 截止时间 */
|
||||
deadline: string
|
||||
/** 创建时间(继承自基础实体) */
|
||||
createTime?: string
|
||||
/** 用户昵称(仅当type=2时有值) */
|
||||
userName?: string
|
||||
}
|
||||
|
||||
/** 黑名单分页查询参数 */
|
||||
export interface ImBlackPageParams {
|
||||
/** 页面索引(从 1 开始) */
|
||||
pageNo: number
|
||||
/** 页面大小 */
|
||||
pageSize: number
|
||||
/** 拉黑类型:1=IP,2=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=IP,2=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=IP,2=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
@@ -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
@@ -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
@@ -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
|
||||
/** 角色 ID:1=群主,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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }> {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ export enum URLEnum {
|
||||
/**角色*/
|
||||
ROLE = '/SysRole',
|
||||
/**租户*/
|
||||
TENANT = '/SysTenant'
|
||||
TENANT = '/SysTenant',
|
||||
/**IM服务*/
|
||||
IM = '/im'
|
||||
}
|
||||
/**权限类型*/
|
||||
export enum FlagEnum {
|
||||
|
||||
@@ -7,7 +7,9 @@ export enum RequestModule {
|
||||
/** System 模块 - 系统后台 */
|
||||
SYSTEM = 'system',
|
||||
/** AI 模块 - AI 相关 */
|
||||
AI = 'ai'
|
||||
AI = 'ai',
|
||||
/** IM 模块 - 即时通讯服务 */
|
||||
IM = 'im'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 || '请求失败')
|
||||
|
||||
306
src/views/composables/modal/friendListModal/index.vue
Normal 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>
|
||||
|
||||
546
src/views/composables/modal/userProfileModal/index.vue
Normal 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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
253
src/views/page/ImContact.vue
Normal 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
327
src/views/page/ImMoment.vue
Normal 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
@@ -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>
|
||||
|
||||
22
src/views/page/MsgCenter.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||