feat(channels): 多渠道网关模块批量功能与样式优化
本次提交完成多渠道网关模块多项核心优化: 1. 新增公共样式库与二维码生成工具,统一列表页交互风格 2. 重构分页、选择、错误处理等通用composable,收敛重复逻辑 3. 调整白名单列表默认分页大小为20,优化数据展示密度 4. 新增目录搜索建议、批量选择与导出能力 5. 优化发件箱与会话统计接口适配,补充趋势数据获取 6. 重构消息列表模块,支持URL状态同步与多维度筛选 7. 修复API错误处理逻辑,补充traceId日志便于排查问题 8. 调整侧边栏导航结构与折叠状态管理
This commit is contained in:
parent
682d851ff5
commit
e6e24a91de
@ -171,7 +171,15 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
|
||||
error.message = '没有权限执行此操作'
|
||||
throw error
|
||||
} else if (response.status === 500) {
|
||||
error.message = '服务器内部错误,请使用 docker logs api-dev 查看详细日志'
|
||||
// 提取后端统一错误响应中的 message 和 trace_id(格式:{success, error: {code, message, trace_id, details}})
|
||||
const serverError = errorData?.error
|
||||
const traceId = serverError?.trace_id
|
||||
error.message = serverError?.message || '服务器内部错误,请稍后重试'
|
||||
if (traceId) {
|
||||
error.traceId = traceId
|
||||
// 控制台记录 trace_id 便于开发者排查,不暴露给用户
|
||||
console.error(`[500] trace_id=${traceId}`, { url, status: response.status })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,9 @@ export const searchDirectoryUsersApi = (channelType, accountId, params = {}) =>
|
||||
/** 搜索渠道群组目录(DIR-QUERY-02) */
|
||||
export const searchDirectoryGroupsApi = (channelType, accountId, params = {}) =>
|
||||
apiAdminGet(`${dirUrl(channelType, accountId, '/groups/search')}${buildQueryString(params)}`)
|
||||
/** 获取搜索建议(DIR-QUERY-SUGGESTIONS) */
|
||||
export const searchDirectorySuggestionsApi = (channelType, accountId, params = {}) =>
|
||||
apiAdminGet(`${dirUrl(channelType, accountId, '/suggestions')}${buildQueryString(params)}`)
|
||||
|
||||
// ===== 单条资料 =====
|
||||
/** 查询渠道用户资料(DIR-QUERY-03) */
|
||||
@ -31,10 +34,25 @@ export const getDirectoryUserProfileApi = (channelType, accountId, peerId) =>
|
||||
export const getDirectoryGroupDetailApi = (channelType, accountId, groupId) =>
|
||||
apiAdminGet(dirUrl(channelType, accountId, `/groups/${groupId}`))
|
||||
|
||||
/** 批量查询对端资料(用户或群组) */
|
||||
/** 批量查询对端资料(用户或群组)
|
||||
* @param {Object} data - 请求体
|
||||
* @param {string[]} [data.types] - 指定资料类型,如 ["user"] 或 ["user", "group"]
|
||||
*/
|
||||
export const batchDirectoryProfilesApi = (channelType, accountId, data) =>
|
||||
apiAdminPost(dirUrl(channelType, accountId, '/batch_profiles'), data)
|
||||
|
||||
// ===== 导出 =====
|
||||
/** 导出目录数据(返回文件流,由调用方处理下载)
|
||||
* @param {Object} data - 请求体
|
||||
* @param {string} data.scope - 导出范围
|
||||
* @param {string} [data.keyword] - 关键词
|
||||
* @param {string} data.format - 导出格式
|
||||
* @param {number} [data.limit] - 限制条数
|
||||
* @param {string} [responseType='blob'] - axios 响应类型
|
||||
*/
|
||||
export const exportDirectoryApi = (channelType, accountId, data = {}, responseType = 'blob') =>
|
||||
apiAdminPost(dirUrl(channelType, accountId, '/export'), data, {}, responseType)
|
||||
|
||||
// ===== 群组成员与缓存 =====
|
||||
/** 查询渠道群组成员(DIR-QUERY-04) */
|
||||
export const getDirectoryGroupMembersApi = (channelType, accountId, groupId, params = {}) =>
|
||||
|
||||
68
web/src/assets/css/channels-common.less
Normal file
68
web/src/assets/css/channels-common.less
Normal file
@ -0,0 +1,68 @@
|
||||
/* Channels 公共样式 */
|
||||
|
||||
.ch-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
background: var(--gray-10);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
&__item--wide {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
&__item--sm {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.ch-table__selected-count {
|
||||
color: var(--gray-600);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ch-form {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-form-item-explain-error {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
:root.dark .ch-filter-bar {
|
||||
background: var(--gray-100);
|
||||
border-color: var(--gray-200);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
:root.dark .ch-table__selected-count {
|
||||
color: var(--gray-400);
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
@import './shorts.css';
|
||||
@import './dashboard.css';
|
||||
@import './code-highlight.less';
|
||||
@import './channels-common.less';
|
||||
|
||||
:root {
|
||||
--header-height: 45px;
|
||||
|
||||
@ -65,3 +65,35 @@ export function canResend(status) {
|
||||
import { useChannelTypes } from './useChannelTypes'
|
||||
const { channelTypeOptions } = useChannelTypes()
|
||||
export { channelTypeOptions as CHANNEL_TYPE_OPTIONS }
|
||||
|
||||
/**
|
||||
* 根据消息记录推断展示类型
|
||||
* @param {object} record - 消息记录
|
||||
* @returns {string} 类型标签
|
||||
*/
|
||||
export function inferMessageType(record) {
|
||||
if (record.image_content || record.message_type === 'image') return '图片'
|
||||
if (record.message_type === 'text') return '文本'
|
||||
if (record.message_type === 'tool_call') return '工具调用'
|
||||
if (record.message_type === 'tool_result') return '工具结果'
|
||||
if (record.content && (record.content.startsWith('{') || record.content.startsWith('['))) return '富媒体'
|
||||
return '其他'
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色(方向)展示配置
|
||||
*/
|
||||
export const ROLE_CONFIG = {
|
||||
admin: { label: '管理员', color: 'blue' },
|
||||
assistant: { label: '助手', color: 'cyan' },
|
||||
user: { label: '对端', color: 'default' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色展示配置
|
||||
* @param {string} role - 角色标识
|
||||
* @returns {{label: string, color: string}}
|
||||
*/
|
||||
export function getRoleMeta(role) {
|
||||
return ROLE_CONFIG[role] || { label: role || '-', color: 'default' }
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ export function useAllowlistManage() {
|
||||
|
||||
// ===== 分页 =====
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// ===== 选中项(批量删除用) =====
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
@ -58,7 +58,7 @@ export function useChannelAccountAllowlist(channelTypeRef, accountIdRef) {
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 选中项(批量删除用)
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
@ -15,10 +15,12 @@ import { ref, reactive, computed, watch, toValue } from 'vue'
|
||||
import {
|
||||
listOutboxMessagesApi,
|
||||
getOutboxStatsApi,
|
||||
getOutboxTrendApi,
|
||||
getOutboxMessageApi,
|
||||
retryOutboxMessageApi
|
||||
} from '@/apis/channels/outbox_api'
|
||||
import { unwrap, toIso } from './utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const POLL_INTERVAL_MS = 30000
|
||||
|
||||
@ -36,6 +38,10 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
const statsLoading = ref(false)
|
||||
const statsError = ref(null)
|
||||
|
||||
const trend = ref(null)
|
||||
const trendLoading = ref(false)
|
||||
const trendError = ref(null)
|
||||
|
||||
const detail = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref(null)
|
||||
@ -71,6 +77,7 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
let pollTimer = null
|
||||
let requestId = 0
|
||||
let statsRequestId = 0
|
||||
let trendRequestId = 0
|
||||
let detailRequestId = 0
|
||||
|
||||
function buildParams() {
|
||||
@ -136,6 +143,34 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrend() {
|
||||
if (!accountId.value) return
|
||||
const currentRequestId = ++trendRequestId
|
||||
trendLoading.value = true
|
||||
trendError.value = null
|
||||
try {
|
||||
const end = dayjs()
|
||||
const start = end.subtract(24, 'hour')
|
||||
const params = {
|
||||
start_time: start.toISOString(),
|
||||
end_time: end.toISOString(),
|
||||
metric: 'queue_depth',
|
||||
granularity: 'hour',
|
||||
channel_account_id: accountId.value
|
||||
}
|
||||
if (channelType.value) params.channel_type = channelType.value
|
||||
const result = unwrap(await getOutboxTrendApi(params))
|
||||
if (currentRequestId !== trendRequestId) return
|
||||
trend.value = result
|
||||
} catch (err) {
|
||||
if (currentRequestId !== trendRequestId) return
|
||||
trendError.value = err
|
||||
trend.value = null
|
||||
} finally {
|
||||
if (currentRequestId === trendRequestId) trendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(outboxId) {
|
||||
if (!outboxId) return
|
||||
const currentRequestId = ++detailRequestId
|
||||
@ -191,7 +226,7 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([fetchMessages(), fetchStats()])
|
||||
await Promise.all([fetchMessages(), fetchStats(), fetchTrend()])
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
@ -220,6 +255,9 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
stats,
|
||||
statsLoading,
|
||||
statsError,
|
||||
trend,
|
||||
trendLoading,
|
||||
trendError,
|
||||
detail,
|
||||
detailLoading,
|
||||
detailError,
|
||||
@ -229,6 +267,7 @@ export function useChannelAccountOutbox(channelTypeRef, accountIdRef) {
|
||||
hasActiveFilters,
|
||||
fetchMessages,
|
||||
fetchStats,
|
||||
fetchTrend,
|
||||
fetchDetail,
|
||||
clearDetail,
|
||||
retryMessage,
|
||||
|
||||
124
web/src/composables/channels/useChannelPagination.js
Normal file
124
web/src/composables/channels/useChannelPagination.js
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 渠道列表页统一分页 Composable
|
||||
*
|
||||
* 统一各列表页 a-table 分页配置,避免分页参数散落各处。
|
||||
* 支持 URL query 同步(page / pageSize),用于刷新与链接分享时保留分页状态。
|
||||
*
|
||||
* 默认配置对齐列表规范:current=1, pageSize=20, showSizeChanger=true,
|
||||
* pageSizeOptions=['10','20','50','100'], showTotal 显示总数。
|
||||
*
|
||||
* 使用方式:
|
||||
* const { pagination, resetPage, current, pageSize } = useChannelPagination({
|
||||
* fetch: (page, pageSize) => loadList(page, pageSize),
|
||||
* syncUrl: true, router, route
|
||||
* })
|
||||
*
|
||||
* URL 同步方向:current/pageSize → URL query(单向)。
|
||||
* 初始化时从 URL query 读取恢复;运行时不再回读 URL,避免循环触发。
|
||||
*/
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 渠道列表页统一分页 composable
|
||||
* @param {Object} options
|
||||
* @param {Function} options.fetch - 触发查询的回调,接收 (page, pageSize)
|
||||
* @param {number} [options.initialPageSize=20] - 初始每页条数
|
||||
* @param {boolean} [options.syncUrl=false] - 是否同步分页到 URL query(page/pageSize)
|
||||
* @param {Object} [options.router] - syncUrl 为 true 时传入的 vue-router useRouter() 实例
|
||||
* @param {Object} [options.route] - syncUrl 为 true 时传入的 useRoute() 实例
|
||||
* @returns {{ pagination: Object, setPagination: Function, resetPage: Function, current: Ref<number>, pageSize: Ref<number> }}
|
||||
*/
|
||||
export function useChannelPagination(options = {}) {
|
||||
const {
|
||||
fetch: fetchFn,
|
||||
initialPageSize = 20,
|
||||
syncUrl = false,
|
||||
router = null,
|
||||
route = null
|
||||
} = options
|
||||
|
||||
// 初始值:syncUrl 时优先从 URL query 读取恢复
|
||||
let initialCurrent = 1
|
||||
let initialSize = initialPageSize
|
||||
if (syncUrl && route) {
|
||||
const qPage = Number(route.query.page)
|
||||
const qPageSize = Number(route.query.pageSize)
|
||||
if (Number.isFinite(qPage) && qPage > 0) initialCurrent = qPage
|
||||
if (Number.isFinite(qPageSize) && qPageSize > 0) initialSize = qPageSize
|
||||
}
|
||||
|
||||
const current = ref(initialCurrent)
|
||||
const pageSize = ref(initialSize)
|
||||
|
||||
// 直接绑定 a-table 的 :pagination 对象;current/pageSize 通过 getter/setter 与 ref 同步
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
get current() {
|
||||
return current.value
|
||||
},
|
||||
set current(v) {
|
||||
current.value = v
|
||||
},
|
||||
get pageSize() {
|
||||
return pageSize.value
|
||||
},
|
||||
set pageSize(v) {
|
||||
pageSize.value = v
|
||||
},
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, size) => {
|
||||
current.value = page
|
||||
pageSize.value = size
|
||||
fetchFn?.(page, size)
|
||||
},
|
||||
onShowSizeChange: (_current, size) => {
|
||||
// 切换每页条数时回到第一页(对齐规范)
|
||||
current.value = 1
|
||||
pageSize.value = size
|
||||
fetchFn?.(1, size)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新分页状态(不触发查询,由调用方按需触发)
|
||||
* @param {{ current?: number, pageSize?: number }} patch
|
||||
*/
|
||||
function setPagination({ current: c, pageSize: ps } = {}) {
|
||||
if (c !== undefined) current.value = c
|
||||
if (ps !== undefined) pageSize.value = ps
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 current 重置为 1(不触发查询,供搜索/重置按钮调用后由调用方自行触发查询)
|
||||
*/
|
||||
function resetPage() {
|
||||
current.value = 1
|
||||
}
|
||||
|
||||
// URL 同步:current / pageSize 变化时写回 query(仅 syncUrl 时启用)
|
||||
// 单向同步避免循环触发:watch 监听 ref 而非 route.query,route 变化不会回弹
|
||||
if (syncUrl && router && route) {
|
||||
watch(
|
||||
[current, pageSize],
|
||||
([c, ps]) => {
|
||||
const nextPage = String(c)
|
||||
const nextSize = String(ps)
|
||||
// 值未变化时跳过,避免无意义的 router.replace
|
||||
if (route.query.page === nextPage && route.query.pageSize === nextSize) return
|
||||
router.replace({
|
||||
query: { ...route.query, page: nextPage, pageSize: nextSize }
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
pagination,
|
||||
setPagination,
|
||||
resetPage,
|
||||
current,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
92
web/src/composables/channels/useChannelSelection.js
Normal file
92
web/src/composables/channels/useChannelSelection.js
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 渠道列表页统一行选择 Composable
|
||||
*
|
||||
* 收敛各批量操作列表页(白名单、outbox、消息列表等)的行选择逻辑:
|
||||
* - 维护已选行 key 数组
|
||||
* - 提供 a-table :row-selection 绑定对象(preserveSelectedRowKeys: true 翻页保留已选)
|
||||
* - 提供当前页全选 / 清空 / 当前页是否全选判断
|
||||
*
|
||||
* 使用方式:
|
||||
* const { rowSelection, selectedRowKeys, selectAllCurrentPage, clearSelection, isCurrentPageAllSelected }
|
||||
* = useChannelSelection({ rowKey: 'id' })
|
||||
*
|
||||
* 与 useChannelAccountAllowlist 内 selectedRowKeys 处理思路一致,
|
||||
* 收敛为通用 composable 避免每处列表页重复实现。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 渠道列表页统一行选择 composable
|
||||
* @param {Object} options
|
||||
* @param {Function|String} options.rowKey - 行 key 的取值函数或字段名(与 a-table rowKey 一致)
|
||||
* @returns {{ selectedRowKeys: Ref<Array>, rowSelection: Object, selectAllCurrentPage: Function, clearSelection: Function, isCurrentPageAllSelected: Function }}
|
||||
*/
|
||||
export function useChannelSelection(options = {}) {
|
||||
const { rowKey } = options
|
||||
|
||||
// 已选行 key 数组
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
/**
|
||||
* 根据配置从行数据中取 key
|
||||
* rowKey 为字符串时取 record[field],为函数时调用 record => key
|
||||
* @param {Object} record - 行数据
|
||||
* @returns {string|number}
|
||||
*/
|
||||
function getRowKey(record) {
|
||||
return typeof rowKey === 'function' ? rowKey(record) : record[rowKey]
|
||||
}
|
||||
|
||||
// a-table :row-selection 绑定对象(preserveSelectedRowKeys: true 保证翻页保留已选)
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
preserveSelectedRowKeys: true
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中当前页所有行(与已选集合去重合并)
|
||||
* @param {Array} dataSource - 当前页数据数组
|
||||
*/
|
||||
function selectAllCurrentPage(dataSource) {
|
||||
if (!Array.isArray(dataSource) || dataSource.length === 0) return
|
||||
const existing = new Set(selectedRowKeys.value)
|
||||
const merged = [...selectedRowKeys.value]
|
||||
for (const record of dataSource) {
|
||||
const key = getRowKey(record)
|
||||
if (!existing.has(key)) {
|
||||
existing.add(key)
|
||||
merged.push(key)
|
||||
}
|
||||
}
|
||||
selectedRowKeys.value = merged
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空已选
|
||||
*/
|
||||
function clearSelection() {
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前页是否已全部选中
|
||||
* @param {Array} dataSource - 当前页数据数组
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isCurrentPageAllSelected(dataSource) {
|
||||
if (!Array.isArray(dataSource) || dataSource.length === 0) return false
|
||||
const selected = new Set(selectedRowKeys.value)
|
||||
return dataSource.every((record) => selected.has(getRowKey(record)))
|
||||
}
|
||||
|
||||
return {
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
selectAllCurrentPage,
|
||||
clearSelection,
|
||||
isCurrentPageAllSelected
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,18 @@ function unwrap(res) {
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造“会话尚未关联 Conversation”的合成错误对象,与后端 NOT_FOUND 结构对齐,
|
||||
* 便于 useSessionError 统一映射为友好空态提示。
|
||||
*/
|
||||
function buildNoConversationError() {
|
||||
return {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'conversation not found',
|
||||
details: { resource: 'conversation' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误日志输出
|
||||
*
|
||||
@ -60,7 +72,7 @@ export function useChannelSessions() {
|
||||
const sessionLoading = ref(false)
|
||||
const sessionError = ref(null)
|
||||
const sessionPage = ref(1)
|
||||
const sessionPageSize = ref(100)
|
||||
const sessionPageSize = ref(20)
|
||||
const sessionFilters = reactive({
|
||||
channel_type: undefined,
|
||||
peer_id: '',
|
||||
@ -137,8 +149,8 @@ export function useChannelSessions() {
|
||||
const res = await getSessionListApi(params)
|
||||
if (requestId !== sessionListRequestId) return
|
||||
const data = unwrap(res)
|
||||
// 后端返回 { sessions: [...], total: N }
|
||||
const items = Array.isArray(data) ? data : data?.sessions || []
|
||||
// 后端返回 { items: [...], total: N }
|
||||
const items = Array.isArray(data) ? data : data?.items || []
|
||||
sessionList.value = items
|
||||
sessionTotal.value = Array.isArray(data) ? items.length : (data?.total ?? 0)
|
||||
} catch (e) {
|
||||
@ -242,7 +254,13 @@ export function useChannelSessions() {
|
||||
const res = await getSessionMessagesApi(targetId, params)
|
||||
if (requestId !== messageRequestId) return
|
||||
const data = unwrap(res)
|
||||
// 后端返回 { messages: [...], total: N }
|
||||
// 后端返回 { messages: [...], total: N, has_conversation }
|
||||
if (data && data.has_conversation === false) {
|
||||
messageList.value = []
|
||||
messageTotal.value = 0
|
||||
messageError.value = buildNoConversationError()
|
||||
return
|
||||
}
|
||||
const items = Array.isArray(data) ? data : data?.messages || []
|
||||
messageList.value = items
|
||||
messageTotal.value = Array.isArray(data) ? items.length : (data?.total ?? 0)
|
||||
@ -282,7 +300,13 @@ export function useChannelSessions() {
|
||||
try {
|
||||
const res = await getSessionStatsApi(targetId)
|
||||
if (requestId !== statsRequestId) return
|
||||
sessionStats.value = unwrap(res)
|
||||
const data = unwrap(res)
|
||||
if (data && data.has_conversation === false) {
|
||||
sessionStats.value = data
|
||||
statsError.value = buildNoConversationError()
|
||||
return
|
||||
}
|
||||
sessionStats.value = data
|
||||
} catch (e) {
|
||||
if (requestId !== statsRequestId) return
|
||||
logError('fetchSessionStats', e)
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
* 设计说明:
|
||||
* - 与 useChannelDashboard.fetchTodos 解耦:Dashboard 是工作台重数据流,
|
||||
* 侧边栏常驻所有 channels 页面,应独立轮询避免每 5s 打 realtime 接口。
|
||||
* - 配对审批 list 接口无 total 字段,用 limit=1 仅判断有无 pending,避免拉全量数据。
|
||||
* - 配对审批 count 接口返回精确 pending 计数,用于侧边栏红标。
|
||||
* - 内容审核 history 返回 total,投递监控 stats 返回 dead,均为精确计数。
|
||||
* - 任一接口失败对应待办保持当前值(不清零),避免误报「无待办」。
|
||||
* - access-ops 域复用 dashboard/overview 的 by_status 与 health 简略端点的 channels[].plugin_state,
|
||||
@ -24,7 +24,7 @@
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getSessionListApi } from '@/apis/channels/session_api'
|
||||
import { listPairingsApi } from '@/apis/channels/pairing_api'
|
||||
import { countPairingsApi } from '@/apis/channels/pairing_api'
|
||||
import { listReviewHistoryApi } from '@/apis/channels/content_review_api'
|
||||
import { getOutboxStatsApi } from '@/apis/channels/outbox_api'
|
||||
import { getDashboardOverviewApi } from '@/apis/channels/dashboard_api'
|
||||
@ -79,8 +79,8 @@ export function useChannelTodos() {
|
||||
loading.value = true
|
||||
// 六路并发,任一失败对应字段保持当前值(不清零)
|
||||
const results = await Promise.allSettled([
|
||||
// 配对审批:list 返回 { pairings: [...] },limit=1 仅判断有无
|
||||
listPairingsApi({ status: 'pending', limit: 1, offset: 0 }),
|
||||
// 配对审批:count 返回 { count: number },取精确计数
|
||||
countPairingsApi({ status: 'pending' }),
|
||||
// 内容审核:history 返回 { total, items },取 total(精确)
|
||||
listReviewHistoryApi({ verdict: 'review', limit: 1, offset: 0 }),
|
||||
// outbox 统计:返回 OutboxStats,取 dead / failed 字段(精确,不传过滤参数表示全局)
|
||||
@ -96,8 +96,7 @@ export function useChannelTodos() {
|
||||
const next = { ...todos.value }
|
||||
if (results[0].status === 'fulfilled') {
|
||||
const data = unwrap(results[0].value)
|
||||
// limit=1 时 pairings.length 为 0 或 1,仅判断有无 pending
|
||||
next.pendingPairings = Array.isArray(data?.pairings) && data.pairings.length > 0 ? 1 : 0
|
||||
next.pendingPairings = Number(data?.count) || 0
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
const data = unwrap(results[1].value)
|
||||
|
||||
94
web/src/composables/channels/useDirectoryError.js
Normal file
94
web/src/composables/channels/useDirectoryError.js
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 目录查询域错误映射。
|
||||
*
|
||||
* 将目录查询相关的异常统一转换为用户友好的错误对象,便于视图层一致展示。
|
||||
*
|
||||
* @module composables/channels/useDirectoryError
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将目录查询错误映射为用户友好的错误对象。
|
||||
*
|
||||
* 保持纯函数风格,不依赖 Vue 实例或响应式能力。调用方可自行用 `computed`
|
||||
* 包装以获得响应性。
|
||||
*
|
||||
* @param {Error|Object|null|undefined} error - 原始错误对象,兼容 axios 错误结构
|
||||
* @returns {{ title: string, description: string, action: null, retryable: boolean }}
|
||||
* 用户友好的错误对象;无错误时返回空文案。
|
||||
*/
|
||||
export function useDirectoryError(error) {
|
||||
const err = error ?? null
|
||||
if (!err) {
|
||||
return { title: '', description: '', action: null, retryable: false }
|
||||
}
|
||||
|
||||
const status = err?.response?.status ?? 0
|
||||
const code = err?.response?.data?.error?.code ?? ''
|
||||
const details = err?.response?.data?.error?.details ?? {}
|
||||
|
||||
// 501 NotImplemented:目录功能未开启或适配器未注册
|
||||
if (status === 501 || code === 'NOT_IMPLEMENTED') {
|
||||
const reason = details.reason
|
||||
if (reason === 'directory_disabled') {
|
||||
return {
|
||||
title: '目录查询已关闭',
|
||||
description: '该账户的目录查询已关闭,请在账户配置的「目录查询」中开启。',
|
||||
action: null,
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
if (reason === 'adapter_not_registered') {
|
||||
return {
|
||||
title: '暂不支持',
|
||||
description: '该渠道暂未接入通讯录查询能力。',
|
||||
action: null,
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 502 DependencyError / 网络错误
|
||||
const isDependencyError = status === 502 || code === 'DEPENDENCY'
|
||||
const isNetworkError =
|
||||
!err?.response || /Network Error/i.test(String(err.message))
|
||||
|
||||
if (isDependencyError || isNetworkError) {
|
||||
const causeCode = details.cause?.code ?? details.cause?.error_code ?? ''
|
||||
const detailsCode = details.code ?? ''
|
||||
|
||||
if (
|
||||
causeCode === 'bridge_url_not_configured' ||
|
||||
detailsCode === 'bridge_url_not_configured'
|
||||
) {
|
||||
return {
|
||||
title: '配置缺失',
|
||||
description: '该账户未配置 bridge 地址,请检查账户凭据。',
|
||||
action: null,
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
|
||||
if (causeCode === 'DB_ENCRYPTED' || detailsCode === 'DB_ENCRYPTED') {
|
||||
return {
|
||||
title: '数据库加密',
|
||||
description: 'Bridge 数据库已加密,无法读取通讯录,请联系运维处理。',
|
||||
action: null,
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: '查询失败',
|
||||
description: '连接渠道服务失败,请检查网络或渠道配置后重试。',
|
||||
action: null,
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: '查询失败',
|
||||
description: err.message || '未知错误',
|
||||
action: null,
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,12 @@
|
||||
* 数据流约束(VNC §2.2):视图层通过本 composable 访问 API,不直接调用 API。
|
||||
* 错误处理(VNC §12):异常存入 error.value,不在此处显示 UI。
|
||||
*/
|
||||
import { ref, computed, isRef } from 'vue'
|
||||
import { ref, computed, isRef, onScopeDispose } from 'vue'
|
||||
import {
|
||||
searchDirectoryUsersApi,
|
||||
searchDirectoryGroupsApi,
|
||||
searchDirectorySuggestionsApi,
|
||||
exportDirectoryApi,
|
||||
getDirectoryUserProfileApi,
|
||||
getDirectoryGroupDetailApi,
|
||||
getDirectoryGroupMembersApi,
|
||||
@ -96,6 +98,10 @@ export function useDirectoryQuery(options = {}) {
|
||||
// ===== 关键词 =====
|
||||
const keyword = ref('')
|
||||
|
||||
// ===== 排序 =====
|
||||
const sortBy = ref('')
|
||||
const sortOrder = ref('')
|
||||
|
||||
// ===== 用户目录列表 =====
|
||||
const users = ref([])
|
||||
const usersLoading = ref(false)
|
||||
@ -136,6 +142,20 @@ export function useDirectoryQuery(options = {}) {
|
||||
const clearingCache = ref(false)
|
||||
const clearCacheError = ref(null)
|
||||
|
||||
// ===== 搜索建议 =====
|
||||
const suggestions = ref([])
|
||||
const suggestionsLoading = ref(false)
|
||||
const suggestionsVisible = ref(false)
|
||||
|
||||
// ===== 批量选择 =====
|
||||
const selectedEntryIds = ref(new Set())
|
||||
const currentList = computed(() =>
|
||||
activeTab.value === DIRECTORY_TAB.USERS ? users.value : groups.value
|
||||
)
|
||||
const selectedEntries = computed(() =>
|
||||
currentList.value.filter((item) => selectedEntryIds.value.has(item.id))
|
||||
)
|
||||
|
||||
// ===== 详情抽屉 =====
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref(DIRECTORY_TAB.USERS)
|
||||
@ -206,6 +226,8 @@ export function useDirectoryQuery(options = {}) {
|
||||
try {
|
||||
const params = { limit: DEFAULT_PAGE_SIZE }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (sortBy.value) params.sort_by = sortBy.value
|
||||
if (sortOrder.value) params.sort_order = sortOrder.value
|
||||
if (!reset && usersCursor.value) params.cursor = usersCursor.value
|
||||
const result = unwrap(
|
||||
await searchDirectoryUsersApi(channelType.value, accountId.value, params)
|
||||
@ -238,6 +260,8 @@ export function useDirectoryQuery(options = {}) {
|
||||
try {
|
||||
const params = { limit: DEFAULT_PAGE_SIZE }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (sortBy.value) params.sort_by = sortBy.value
|
||||
if (sortOrder.value) params.sort_order = sortOrder.value
|
||||
if (!reset && groupsCursor.value) params.cursor = groupsCursor.value
|
||||
const result = unwrap(
|
||||
await searchDirectoryGroupsApi(channelType.value, accountId.value, params)
|
||||
@ -269,6 +293,95 @@ export function useDirectoryQuery(options = {}) {
|
||||
return searchGroups(false)
|
||||
}
|
||||
|
||||
/** 切换 Tab:保留关键词、清空选择、触发搜索 */
|
||||
function switchTab(tab) {
|
||||
if (activeTab.value === tab) return
|
||||
activeTab.value = tab
|
||||
clearSelection()
|
||||
search()
|
||||
}
|
||||
|
||||
// ===== 搜索建议 =====
|
||||
let suggestionsTimer = null
|
||||
|
||||
async function fetchSuggestions(rawKeyword, limit = 10) {
|
||||
if (suggestionsTimer) {
|
||||
clearTimeout(suggestionsTimer)
|
||||
suggestionsTimer = null
|
||||
}
|
||||
const trimmed = String(rawKeyword || '').trim()
|
||||
if (!trimmed) {
|
||||
suggestions.value = []
|
||||
suggestionsVisible.value = false
|
||||
return
|
||||
}
|
||||
suggestionsTimer = setTimeout(async () => {
|
||||
if (!isReady.value) return
|
||||
suggestionsLoading.value = true
|
||||
try {
|
||||
const result = unwrap(
|
||||
await searchDirectorySuggestionsApi(channelType.value, accountId.value, {
|
||||
keyword: trimmed,
|
||||
limit
|
||||
})
|
||||
)
|
||||
suggestions.value = result?.entries || []
|
||||
suggestionsVisible.value = suggestions.value.length > 0
|
||||
} catch {
|
||||
suggestions.value = []
|
||||
suggestionsVisible.value = false
|
||||
} finally {
|
||||
suggestionsLoading.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (suggestionsTimer) {
|
||||
clearTimeout(suggestionsTimer)
|
||||
suggestionsTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function hideSuggestions() {
|
||||
if (suggestionsTimer) {
|
||||
clearTimeout(suggestionsTimer)
|
||||
suggestionsTimer = null
|
||||
}
|
||||
suggestionsVisible.value = false
|
||||
}
|
||||
|
||||
// ===== 导出 =====
|
||||
async function exportDirectory(scope, format = 'csv', limit = 1000) {
|
||||
if (!isReady.value) {
|
||||
throw new Error('渠道或账户未选择')
|
||||
}
|
||||
const data = { scope, format, limit }
|
||||
if (keyword.value) data.keyword = keyword.value
|
||||
return exportDirectoryApi(channelType.value, accountId.value, data, 'blob')
|
||||
}
|
||||
|
||||
// ===== 批量选择 =====
|
||||
function toggleSelection(entry) {
|
||||
if (!entry?.id) return
|
||||
const next = new Set(selectedEntryIds.value)
|
||||
if (next.has(entry.id)) {
|
||||
next.delete(entry.id)
|
||||
} else {
|
||||
next.add(entry.id)
|
||||
}
|
||||
selectedEntryIds.value = next
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
const ids = currentList.value.map((item) => item.id).filter(Boolean)
|
||||
selectedEntryIds.value = new Set(ids)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedEntryIds.value = new Set()
|
||||
}
|
||||
|
||||
// ===== 用户资料 =====
|
||||
async function fetchUserProfile(peerId) {
|
||||
userProfileLoading.value = true
|
||||
@ -399,8 +512,18 @@ export function useDirectoryQuery(options = {}) {
|
||||
fetchAccounts,
|
||||
// Tab
|
||||
activeTab,
|
||||
switchTab,
|
||||
// 关键词
|
||||
keyword,
|
||||
// 排序
|
||||
sortBy,
|
||||
sortOrder,
|
||||
// 搜索建议
|
||||
suggestions,
|
||||
suggestionsLoading,
|
||||
suggestionsVisible,
|
||||
fetchSuggestions,
|
||||
hideSuggestions,
|
||||
// 用户列表
|
||||
users,
|
||||
usersLoading,
|
||||
@ -415,6 +538,15 @@ export function useDirectoryQuery(options = {}) {
|
||||
groupsHasMore,
|
||||
groupsLoadingMore,
|
||||
groupsSearched,
|
||||
// 批量选择
|
||||
currentList,
|
||||
selectedEntryIds,
|
||||
selectedEntries,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
clearSelection,
|
||||
// 导出
|
||||
exportDirectory,
|
||||
// 详情
|
||||
detailVisible,
|
||||
detailType,
|
||||
|
||||
@ -14,7 +14,9 @@
|
||||
* - 撤回:{ message_id, channel_msg_id, channel_recalled_at, im_side_recall_ack }
|
||||
* - 重发:{ original_message_id, new_message_id, sent_at }
|
||||
*/
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
listAdminMessagesApi,
|
||||
searchMessagesApi,
|
||||
@ -25,11 +27,58 @@ import {
|
||||
resendMessageApi
|
||||
} from '@/apis/channels/message_api'
|
||||
|
||||
// 历史列表默认分页(API §12.1 limit 1-200 默认 50)
|
||||
const HISTORY_DEFAULT_PAGE_SIZE = 50
|
||||
// 历史列表默认分页(API §12.1 limit 1-200,前端默认 20 对齐列表规范)
|
||||
const HISTORY_DEFAULT_PAGE_SIZE = 20
|
||||
// 搜索列表默认分页(API §12.2 limit 1-100 默认 20)
|
||||
const SEARCH_DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
// 历史 Tab 筛选字段(用于构造 API 参数与 URL query)
|
||||
const HISTORY_FILTER_KEYS = [
|
||||
'channel_type',
|
||||
'channel_status',
|
||||
'role',
|
||||
'account_id',
|
||||
'peer_id',
|
||||
'message_id',
|
||||
'channel_msg_id',
|
||||
'channel_session_id',
|
||||
'time_range'
|
||||
]
|
||||
|
||||
// 搜索 Tab 筛选字段
|
||||
const SEARCH_FILTER_KEYS = [
|
||||
'keyword',
|
||||
'channel_type',
|
||||
'channel_status',
|
||||
'role',
|
||||
'peer_id',
|
||||
'message_id',
|
||||
'channel_msg_id',
|
||||
'channel_session_id',
|
||||
'time_range'
|
||||
]
|
||||
|
||||
// 后端支持逗号分隔的数组字段
|
||||
const ARRAY_FILTER_KEYS = new Set(['channel_status', 'role'])
|
||||
|
||||
// 受 URL query 同步管理的 key 集合(切换 Tab 时会清理非当前 Tab 的字段)
|
||||
const MANAGED_QUERY_KEYS = new Set([
|
||||
'tab',
|
||||
'page',
|
||||
'pageSize',
|
||||
'keyword',
|
||||
'channel_type',
|
||||
'channel_status',
|
||||
'role',
|
||||
'account_id',
|
||||
'peer_id',
|
||||
'message_id',
|
||||
'channel_msg_id',
|
||||
'channel_session_id',
|
||||
'start_time',
|
||||
'end_time'
|
||||
])
|
||||
|
||||
/**
|
||||
* 解包后端统一响应 { success, data } → data
|
||||
* 非 envelope 结构原样返回
|
||||
@ -57,6 +106,11 @@ function toIso(value) {
|
||||
}
|
||||
|
||||
export function useMessage() {
|
||||
// ===== 路由(用于 URL query 双向同步) =====
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isRestoringFromUrl = ref(false)
|
||||
|
||||
// ===== Tab =====
|
||||
const activeTab = ref('history') // 'history' | 'search'
|
||||
|
||||
@ -70,16 +124,30 @@ export function useMessage() {
|
||||
pageSize: HISTORY_DEFAULT_PAGE_SIZE
|
||||
})
|
||||
|
||||
// 历史筛选(API §12.1:channel_type / start_time / end_time)
|
||||
// 历史筛选(API §12.1:channel_type / channel_status / role / account_id / peer_id /
|
||||
// message_id / channel_msg_id / channel_session_id / start_time / end_time)
|
||||
const historyFilters = reactive({
|
||||
channel_type: undefined,
|
||||
channel_status: undefined,
|
||||
role: undefined,
|
||||
account_id: '',
|
||||
peer_id: '',
|
||||
message_id: '',
|
||||
channel_msg_id: '',
|
||||
channel_session_id: '',
|
||||
time_range: undefined // [start, end]
|
||||
})
|
||||
|
||||
// 搜索筛选(API §12.2:keyword / channel_type / channel_session_id / start_time / end_time)
|
||||
// 搜索筛选(API §12.2:keyword / channel_type / channel_status / role / peer_id /
|
||||
// message_id / channel_msg_id / channel_session_id / start_time / end_time)
|
||||
const searchFilters = reactive({
|
||||
keyword: '',
|
||||
channel_type: undefined,
|
||||
channel_status: undefined,
|
||||
role: undefined,
|
||||
peer_id: '',
|
||||
message_id: '',
|
||||
channel_msg_id: '',
|
||||
channel_session_id: '',
|
||||
time_range: undefined
|
||||
})
|
||||
@ -100,6 +168,201 @@ export function useMessage() {
|
||||
|
||||
const isEmpty = computed(() => !list.value?.length)
|
||||
|
||||
// ===== 分页默认值 =====
|
||||
|
||||
function getDefaultPageSize(tab = activeTab.value) {
|
||||
return tab === 'history' ? HISTORY_DEFAULT_PAGE_SIZE : SEARCH_DEFAULT_PAGE_SIZE
|
||||
}
|
||||
|
||||
// ===== 筛选条件工具 =====
|
||||
|
||||
/**
|
||||
* 将指定 Tab 的筛选条件重置为初始值
|
||||
*/
|
||||
function resetFiltersToInitial(tab = activeTab.value) {
|
||||
if (tab === 'history') {
|
||||
historyFilters.channel_type = undefined
|
||||
historyFilters.channel_status = undefined
|
||||
historyFilters.role = undefined
|
||||
historyFilters.account_id = ''
|
||||
historyFilters.peer_id = ''
|
||||
historyFilters.message_id = ''
|
||||
historyFilters.channel_msg_id = ''
|
||||
historyFilters.channel_session_id = ''
|
||||
historyFilters.time_range = undefined
|
||||
} else {
|
||||
searchFilters.keyword = ''
|
||||
searchFilters.channel_type = undefined
|
||||
searchFilters.channel_status = undefined
|
||||
searchFilters.role = undefined
|
||||
searchFilters.peer_id = ''
|
||||
searchFilters.message_id = ''
|
||||
searchFilters.channel_msg_id = ''
|
||||
searchFilters.channel_session_id = ''
|
||||
searchFilters.time_range = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为当前 Tab 构造 API 查询参数
|
||||
*/
|
||||
function buildListParams() {
|
||||
const params = {
|
||||
limit: pagination.pageSize,
|
||||
offset: (pagination.current - 1) * pagination.pageSize
|
||||
}
|
||||
const filterKeys = activeTab.value === 'history' ? HISTORY_FILTER_KEYS : SEARCH_FILTER_KEYS
|
||||
const filters = activeTab.value === 'history' ? historyFilters : searchFilters
|
||||
filterKeys.forEach((key) => {
|
||||
if (key === 'time_range') {
|
||||
const start = toIso(filters.time_range?.[0])
|
||||
const end = toIso(filters.time_range?.[1])
|
||||
if (start) params.start_time = start
|
||||
if (end) params.end_time = end
|
||||
return
|
||||
}
|
||||
const val = filters[key]
|
||||
if (val === undefined || val === null || val === '') return
|
||||
params[key] = ARRAY_FILTER_KEYS.has(key) ? val.join(',') : val
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
// ===== URL query 同步 =====
|
||||
|
||||
/**
|
||||
* 将当前 Tab、分页、筛选条件序列化为 URL query 对象
|
||||
*/
|
||||
function getQueryParams() {
|
||||
const query = {}
|
||||
query.tab = activeTab.value
|
||||
if (pagination.current > 1) query.page = String(pagination.current)
|
||||
if (pagination.pageSize !== getDefaultPageSize()) {
|
||||
query.pageSize = String(pagination.pageSize)
|
||||
}
|
||||
const filters = activeTab.value === 'history' ? historyFilters : searchFilters
|
||||
Object.entries(filters).forEach(([key, val]) => {
|
||||
if (val === undefined || val === null || val === '') return
|
||||
if (key === 'time_range') {
|
||||
const start = toIso(val?.[0])
|
||||
const end = toIso(val?.[1])
|
||||
if (start) query.start_time = start
|
||||
if (end) query.end_time = end
|
||||
} else if (Array.isArray(val)) {
|
||||
if (val.length) query[key] = val.join(',')
|
||||
} else {
|
||||
query[key] = String(val)
|
||||
}
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前状态同步到 URL query(不增加历史记录)
|
||||
*/
|
||||
function syncUrl() {
|
||||
if (isRestoringFromUrl.value || !route || !router) return
|
||||
const query = { ...route.query }
|
||||
MANAGED_QUERY_KEYS.forEach((k) => delete query[k])
|
||||
Object.assign(query, getQueryParams())
|
||||
router.replace({ path: route.path, query })
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL query 恢复 Tab、分页与当前 Tab 的筛选条件
|
||||
* @param {object} query - route.query
|
||||
* @returns {boolean} 是否有字段被恢复
|
||||
*/
|
||||
function loadFromQuery(query) {
|
||||
if (!query || isRestoringFromUrl.value) return false
|
||||
isRestoringFromUrl.value = true
|
||||
let changed = false
|
||||
|
||||
const tab = query.tab === 'search' ? 'search' : 'history'
|
||||
if (activeTab.value !== tab) {
|
||||
activeTab.value = tab
|
||||
pagination.pageSize = getDefaultPageSize(tab)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (query.page) {
|
||||
const page = Number(query.page)
|
||||
if (!Number.isNaN(page) && page > 0) {
|
||||
pagination.current = page
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (query.pageSize) {
|
||||
const ps = Number(query.pageSize)
|
||||
if (!Number.isNaN(ps) && ps > 0) {
|
||||
pagination.pageSize = ps
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
resetFiltersToInitial(tab)
|
||||
|
||||
const filters = tab === 'history' ? historyFilters : searchFilters
|
||||
const filterKeys = tab === 'history' ? HISTORY_FILTER_KEYS : SEARCH_FILTER_KEYS
|
||||
filterKeys.forEach((key) => {
|
||||
if (key === 'time_range') {
|
||||
if (query.start_time || query.end_time) {
|
||||
const start = query.start_time ? dayjs(query.start_time) : undefined
|
||||
const end = query.end_time ? dayjs(query.end_time) : undefined
|
||||
if ((start && start.isValid()) || (end && end.isValid())) {
|
||||
filters.time_range = [start, end]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (query[key] === undefined) return
|
||||
const raw = query[key]
|
||||
if (ARRAY_FILTER_KEYS.has(key)) {
|
||||
const arr = Array.isArray(raw) ? raw : String(raw).split(',').filter(Boolean)
|
||||
filters[key] = arr.length ? arr : undefined
|
||||
} else {
|
||||
filters[key] = String(raw)
|
||||
}
|
||||
changed = true
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
isRestoringFromUrl.value = false
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
// ===== URL 同步监听器 =====
|
||||
// 使用 post flush 确保在视图层 activeTab watcher 之后执行,最终 URL 以本 composable 为准
|
||||
|
||||
watch(
|
||||
() => [activeTab.value, pagination.current, pagination.pageSize],
|
||||
() => syncUrl(),
|
||||
{ flush: 'post' }
|
||||
)
|
||||
watch(
|
||||
historyFilters,
|
||||
() => {
|
||||
if (activeTab.value === 'history') syncUrl()
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
watch(
|
||||
searchFilters,
|
||||
() => {
|
||||
if (activeTab.value === 'search') syncUrl()
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
|
||||
// 页面初始化时从 URL 恢复状态
|
||||
onMounted(() => {
|
||||
if (route && Object.keys(route.query).length) {
|
||||
loadFromQuery(route.query)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 列表查询 =====
|
||||
|
||||
/**
|
||||
@ -110,31 +373,19 @@ export function useMessage() {
|
||||
listError.value = null
|
||||
try {
|
||||
if (activeTab.value === 'history') {
|
||||
const params = {
|
||||
limit: pagination.pageSize,
|
||||
offset: (pagination.current - 1) * pagination.pageSize
|
||||
}
|
||||
if (historyFilters.channel_type) params.channel_type = historyFilters.channel_type
|
||||
const start = toIso(historyFilters.time_range?.[0])
|
||||
const end = toIso(historyFilters.time_range?.[1])
|
||||
if (start) params.start_time = start
|
||||
if (end) params.end_time = end
|
||||
const params = buildListParams()
|
||||
const data = unwrap(await listAdminMessagesApi(params)) || {}
|
||||
list.value = data.messages || []
|
||||
total.value = data.total || 0
|
||||
} else {
|
||||
const params = {
|
||||
limit: pagination.pageSize,
|
||||
offset: (pagination.current - 1) * pagination.pageSize
|
||||
const kw = (searchFilters.keyword || '').trim()
|
||||
if (kw.length < 2) {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
listLoading.value = false
|
||||
return
|
||||
}
|
||||
if (searchFilters.keyword) params.keyword = searchFilters.keyword
|
||||
if (searchFilters.channel_type) params.channel_type = searchFilters.channel_type
|
||||
if (searchFilters.channel_session_id)
|
||||
params.channel_session_id = searchFilters.channel_session_id
|
||||
const start = toIso(searchFilters.time_range?.[0])
|
||||
const end = toIso(searchFilters.time_range?.[1])
|
||||
if (start) params.start_time = start
|
||||
if (end) params.end_time = end
|
||||
const params = buildListParams()
|
||||
const data = unwrap(await searchMessagesApi(params)) || {}
|
||||
list.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
@ -154,26 +405,19 @@ export function useMessage() {
|
||||
function switchTab(tab) {
|
||||
activeTab.value = tab
|
||||
pagination.current = 1
|
||||
pagination.pageSize = tab === 'history' ? HISTORY_DEFAULT_PAGE_SIZE : SEARCH_DEFAULT_PAGE_SIZE
|
||||
resetFilters()
|
||||
pagination.pageSize = getDefaultPageSize(tab)
|
||||
resetFiltersToInitial(tab)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
listError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置当前 Tab 的筛选项至空值(不发起新请求)
|
||||
* 重置当前 Tab 的筛选项至初始值,并回到第 1 页(不自动发起请求)
|
||||
*/
|
||||
function resetFilters() {
|
||||
if (activeTab.value === 'history') {
|
||||
historyFilters.channel_type = undefined
|
||||
historyFilters.time_range = undefined
|
||||
} else {
|
||||
searchFilters.keyword = ''
|
||||
searchFilters.channel_type = undefined
|
||||
searchFilters.channel_session_id = ''
|
||||
searchFilters.time_range = undefined
|
||||
}
|
||||
resetFiltersToInitial()
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function setPagination({ page, pageSize } = {}) {
|
||||
@ -318,6 +562,9 @@ export function useMessage() {
|
||||
fetchList,
|
||||
resetFilters,
|
||||
setPagination,
|
||||
// URL 同步
|
||||
getQueryParams,
|
||||
loadFromQuery,
|
||||
// 批量撤回
|
||||
batchRecalling,
|
||||
batchResult,
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
* - 所有端点统一返回 { success: true, data: <DTO> },composable 通过 unwrap 解包 .data
|
||||
* - list messages / dead-letter 响应:{ items: [OutboxEntry, ...], total }
|
||||
* - detail 响应:单个 OutboxEntry 字典
|
||||
* - stats 响应:{ total, pending, sent, suppressed, failed, sent_unconfirmed, dead }
|
||||
* - stats 响应:{ total, pending, sent, suppressed, failed, sent_unconfirmed, dead,
|
||||
* top_errors, avg_latency_ms, oldest_pending_at }
|
||||
* - trend 响应:{ metric, granularity, series: [{ timestamp, value }] }
|
||||
* - retry-policy 响应:{ max_retry, ttl_seconds, retry_backoff_schedule, updated_at }
|
||||
* - batch-retry 响应:{ retried_count, queued }(作用于全部死信,无 outbox_ids 参数)
|
||||
|
||||
85
web/src/composables/channels/useSessionError.js
Normal file
85
web/src/composables/channels/useSessionError.js
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 会话错误映射层
|
||||
*
|
||||
* 将后端结构化错误 { code, message, details } 映射为用户友好的展示对象。
|
||||
* 支持直接传入统一错误对象,也支持从 apiRequest 抛出的 Error 对象中解析。
|
||||
*
|
||||
* @typedef {Object} BackendError
|
||||
* @property {string} code - 后端错误码
|
||||
* @property {string} message - 后端错误信息
|
||||
* @property {Object} [details] - 错误详情,常见字段 resource / field
|
||||
*
|
||||
* @typedef {Object} SessionErrorMeta
|
||||
* @property {string} title - 错误标题
|
||||
* @property {string} description - 错误描述
|
||||
* @property {string} action - 操作按钮/提示文案
|
||||
* @property {boolean} retryable - 是否展示重试按钮
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从可能的不同形态错误中抽取统一错误对象
|
||||
* @param {BackendError|Error|string|null} error
|
||||
* @returns {BackendError|null}
|
||||
*/
|
||||
function extractPayload(error) {
|
||||
if (!error) return null
|
||||
if (error.response?.data?.error) return error.response.data.error
|
||||
if (error.code) return error
|
||||
if (typeof error === 'string') return { message: error }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射后端错误为用户友好的展示对象
|
||||
* @param {BackendError|Error|string|null} error
|
||||
* @returns {SessionErrorMeta}
|
||||
*/
|
||||
export function useSessionError(error) {
|
||||
const payload = extractPayload(error)
|
||||
const { code, message, details } = payload || {}
|
||||
|
||||
switch (code) {
|
||||
case 'NOT_FOUND':
|
||||
if (details?.resource === 'conversation') {
|
||||
return {
|
||||
title: '暂无消息记录',
|
||||
description: '该渠道会话尚未产生消息记录',
|
||||
action: '刷新列表',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
if (details?.resource === 'session' || details?.resource === 'channel_session') {
|
||||
return {
|
||||
title: '会话不存在',
|
||||
description: '会话不存在或已被删除',
|
||||
action: '刷新列表',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'VALIDATION_ERROR':
|
||||
if (details?.field === 'conversation_id') {
|
||||
return {
|
||||
title: '参数格式错误',
|
||||
description: '会话关联的消息记录 ID 格式不正确',
|
||||
action: '联系技术支持',
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'RULE_VIOLATION':
|
||||
return {
|
||||
title: '合并策略未启用',
|
||||
description: '合并策略未启用,请联系管理员开启',
|
||||
action: '联系管理员',
|
||||
retryable: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: '请求失败',
|
||||
description: message || '请求失败,请稍后重试',
|
||||
action: '重试',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,10 @@ import { useChannelTypes } from './useChannelTypes'
|
||||
const { channelTypeOptions } = useChannelTypes()
|
||||
export { channelTypeOptions as CHANNEL_TYPE_OPTIONS }
|
||||
|
||||
// 消息操作可用性判断复用 messageConstants 的归一化实现(小写 + 完整状态集合),
|
||||
// 避免本文件维护重复的状态矩阵导致大小写不一致。
|
||||
import { canRecall as _canRecall, canResend as _canResend } from './messageConstants'
|
||||
|
||||
// ===== 会话状态映射 =====
|
||||
export const SESSION_STATUS_MAP = {
|
||||
active: { label: '活跃', color: 'success' },
|
||||
@ -48,23 +52,23 @@ export function getMessageStatusMeta(status) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否可撤回(对齐原型图 §5.2 状态矩阵)
|
||||
* SENT / DELIVERED / READ 可撤回
|
||||
* 判断消息是否可撤回(委托 messageConstants.canRecall,带大小写归一化)
|
||||
* 可撤回:sent / delivered / read / edited
|
||||
* @param {string} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canRecallMessage(status) {
|
||||
return ['SENT', 'DELIVERED', 'READ'].includes(status)
|
||||
return _canRecall(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否可重发(对齐原型图 §5.2 状态矩阵)
|
||||
* SENT / DELIVERED / READ / FAILED 可重发
|
||||
* 判断消息是否可重发(委托 messageConstants.canResend,带大小写归一化)
|
||||
* 不可重发:recalled / deleted
|
||||
* @param {string} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canResendMessage(status) {
|
||||
return ['SENT', 'DELIVERED', 'READ', 'FAILED'].includes(status)
|
||||
return _canResend(status)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -63,6 +63,37 @@ export function formatPercent(val) {
|
||||
return `${n.toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化持续时长(毫秒 → 中文可读)
|
||||
* @param {number|string|Date|null|undefined} ms - 毫秒数或两个时间中的较晚者
|
||||
* @param {string|Date|null|undefined} [since] - 起始时间;传入时计算 ms - since
|
||||
* @returns {string} 如 `2小时 15分` / `1分 30秒` 或 `-`
|
||||
*/
|
||||
export function formatDuration(ms, since) {
|
||||
if (ms == null) return '-'
|
||||
let totalMs
|
||||
if (since != null) {
|
||||
const end = ms instanceof Date ? ms.getTime() : new Date(ms).getTime()
|
||||
const start = since instanceof Date ? since.getTime() : new Date(since).getTime()
|
||||
totalMs = end - start
|
||||
} else {
|
||||
totalMs = Number(ms)
|
||||
}
|
||||
if (Number.isNaN(totalMs) || totalMs < 0) return '-'
|
||||
const totalSeconds = Math.floor(totalMs / 1000)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days}天`)
|
||||
if (hours > 0) parts.push(`${hours}小时`)
|
||||
if (minutes > 0) parts.push(`${minutes}分`)
|
||||
if (seconds > 0 && parts.length < 2) parts.push(`${seconds}秒`)
|
||||
if (parts.length === 0) parts.push('0秒')
|
||||
return parts.slice(0, 2).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化延迟(毫秒)
|
||||
* @param {number|null|undefined} ms
|
||||
|
||||
@ -47,6 +47,21 @@ const { threads, currentThreadId, hasMoreThreads, isLoadingMoreThreads } =
|
||||
const githubStars = ref(0)
|
||||
const isLoadingStars = ref(false)
|
||||
|
||||
const fetchGithubStars = async () => {
|
||||
isLoadingStars.value = true
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/xerrors/Yuxi')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
githubStars.value = data.stargazers_count || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('获取 GitHub stars 失败:', error)
|
||||
} finally {
|
||||
isLoadingStars.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add state for debug modal
|
||||
const showDebugModal = ref(false)
|
||||
|
||||
@ -149,6 +164,13 @@ const mainList = computed(() => {
|
||||
path: '/dashboard',
|
||||
icon: BarChart3,
|
||||
activeIcon: BarChart3
|
||||
})
|
||||
items.push({
|
||||
name: '多渠道网关',
|
||||
path: '/channels',
|
||||
activePaths: ['/channels'],
|
||||
icon: Cable,
|
||||
activeIcon: Cable
|
||||
})
|
||||
items.push({
|
||||
name: '外部系统集成',
|
||||
@ -157,13 +179,6 @@ const mainList = computed(() => {
|
||||
icon: Network,
|
||||
activeIcon: Network
|
||||
})
|
||||
items.push({
|
||||
name: '多渠道网关',
|
||||
path: '/channels',
|
||||
activePaths: ['/channels'],
|
||||
icon: Cable,
|
||||
activeIcon: Cable
|
||||
})
|
||||
items.push({
|
||||
name: '定时任务',
|
||||
path: '/scheduler',
|
||||
|
||||
@ -418,6 +418,12 @@ const router = createRouter({
|
||||
component: () => import('../components/channels/access-ops/AccountDetailView.vue'),
|
||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'route-bindings',
|
||||
name: 'ChannelsRouteBindings',
|
||||
component: () => import('../components/channels/access-ops/RouteBindingView.vue'),
|
||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'capabilities',
|
||||
name: 'ChannelsCapabilities',
|
||||
@ -478,7 +484,7 @@ const router = createRouter({
|
||||
{
|
||||
path: 'plugins',
|
||||
name: 'ChannelsPlugins',
|
||||
component: () => import('../components/channels/system-governance/PluginManageView.vue'),
|
||||
component: () => import('../components/channels/access-ops/PluginManageView.vue'),
|
||||
meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
@ -493,12 +499,6 @@ const router = createRouter({
|
||||
name: 'ChannelsAnalytics',
|
||||
component: () => import('../components/channels/analytics/AnalyticsView.vue'),
|
||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'ChannelsReports',
|
||||
component: () => import('../components/channels/analytics/ReportManageView.vue'),
|
||||
meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -11,6 +11,9 @@ export const useChatUIStore = defineStore(
|
||||
// 应用侧边栏折叠态
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 多渠道网关侧边栏折叠态(L2,独立于 L1)
|
||||
const channelsNavCollapsed = ref(false)
|
||||
|
||||
// 更多菜单
|
||||
const moreMenuOpen = ref(false)
|
||||
const moreMenuPosition = ref({ x: 0, y: 0 })
|
||||
@ -46,6 +49,7 @@ export const useChatUIStore = defineStore(
|
||||
// 状态
|
||||
isLoadingMessages,
|
||||
sidebarCollapsed,
|
||||
channelsNavCollapsed,
|
||||
moreMenuOpen,
|
||||
moreMenuPosition,
|
||||
|
||||
@ -59,7 +63,7 @@ export const useChatUIStore = defineStore(
|
||||
persist: {
|
||||
key: 'chat-ui-store',
|
||||
storage: localStorage,
|
||||
pick: ['sidebarCollapsed']
|
||||
pick: ['sidebarCollapsed', 'channelsNavCollapsed']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
27
web/src/utils/qrcode.js
Normal file
27
web/src/utils/qrcode.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 二维码生成工具
|
||||
*
|
||||
* 后端返回的 qr_data_url 通常是渠道登录跳转链接(如微信 liteapp),
|
||||
* 不能直接作为 <img src> 使用。本工具将其渲染为 base64 data URL,
|
||||
* 供前端组件展示二维码图片。
|
||||
*/
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
/**
|
||||
* 把文本渲染为二维码 base64 data URL。
|
||||
*
|
||||
* @param {string} text - 要编码的文本/URL
|
||||
* @param {object} options - QRCode.toDataURL 选项
|
||||
* @returns {Promise<string>} base64 data URL
|
||||
*/
|
||||
export async function generateQrDataUrl(text, options = {}) {
|
||||
return QRCode.toDataURL(text, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
},
|
||||
...options
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user