diff --git a/web/src/apis/base.js b/web/src/apis/base.js index f23fb5ab..9e6b9bf5 100644 --- a/web/src/apis/base.js +++ b/web/src/apis/base.js @@ -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 } diff --git a/web/src/apis/channels/directory_api.js b/web/src/apis/channels/directory_api.js index 2012353f..d9a79868 100644 --- a/web/src/apis/channels/directory_api.js +++ b/web/src/apis/channels/directory_api.js @@ -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 = {}) => diff --git a/web/src/assets/css/channels-common.less b/web/src/assets/css/channels-common.less new file mode 100644 index 00000000..aa54fa97 --- /dev/null +++ b/web/src/assets/css/channels-common.less @@ -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); +} diff --git a/web/src/assets/css/main.css b/web/src/assets/css/main.css index 74a03561..d1712f5a 100644 --- a/web/src/assets/css/main.css +++ b/web/src/assets/css/main.css @@ -4,6 +4,7 @@ @import './shorts.css'; @import './dashboard.css'; @import './code-highlight.less'; +@import './channels-common.less'; :root { --header-height: 45px; diff --git a/web/src/composables/channels/messageConstants.js b/web/src/composables/channels/messageConstants.js index 90be9388..3ac621dc 100644 --- a/web/src/composables/channels/messageConstants.js +++ b/web/src/composables/channels/messageConstants.js @@ -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' } +} diff --git a/web/src/composables/channels/useAllowlistManage.js b/web/src/composables/channels/useAllowlistManage.js index 26bbd028..91b37593 100644 --- a/web/src/composables/channels/useAllowlistManage.js +++ b/web/src/composables/channels/useAllowlistManage.js @@ -121,7 +121,7 @@ export function useAllowlistManage() { // ===== 分页 ===== const currentPage = ref(1) - const pageSize = ref(50) + const pageSize = ref(20) // ===== 选中项(批量删除用) ===== const selectedRowKeys = ref([]) diff --git a/web/src/composables/channels/useChannelAccountAllowlist.js b/web/src/composables/channels/useChannelAccountAllowlist.js index 924a8841..2db254b7 100644 --- a/web/src/composables/channels/useChannelAccountAllowlist.js +++ b/web/src/composables/channels/useChannelAccountAllowlist.js @@ -58,7 +58,7 @@ export function useChannelAccountAllowlist(channelTypeRef, accountIdRef) { // 分页 const currentPage = ref(1) - const pageSize = ref(50) + const pageSize = ref(20) // 选中项(批量删除用) const selectedRowKeys = ref([]) diff --git a/web/src/composables/channels/useChannelAccountOutbox.js b/web/src/composables/channels/useChannelAccountOutbox.js index 571859c7..be9416f3 100644 --- a/web/src/composables/channels/useChannelAccountOutbox.js +++ b/web/src/composables/channels/useChannelAccountOutbox.js @@ -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, diff --git a/web/src/composables/channels/useChannelPagination.js b/web/src/composables/channels/useChannelPagination.js new file mode 100644 index 00000000..bba3efc9 --- /dev/null +++ b/web/src/composables/channels/useChannelPagination.js @@ -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, pageSize: Ref }} + */ +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 + } +} diff --git a/web/src/composables/channels/useChannelSelection.js b/web/src/composables/channels/useChannelSelection.js new file mode 100644 index 00000000..3fc48fce --- /dev/null +++ b/web/src/composables/channels/useChannelSelection.js @@ -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, 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 + } +} diff --git a/web/src/composables/channels/useChannelSessions.js b/web/src/composables/channels/useChannelSessions.js index 64f315f7..e820eb13 100644 --- a/web/src/composables/channels/useChannelSessions.js +++ b/web/src/composables/channels/useChannelSessions.js @@ -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) diff --git a/web/src/composables/channels/useChannelTodos.js b/web/src/composables/channels/useChannelTodos.js index 123b82a4..17122447 100644 --- a/web/src/composables/channels/useChannelTodos.js +++ b/web/src/composables/channels/useChannelTodos.js @@ -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) diff --git a/web/src/composables/channels/useDirectoryError.js b/web/src/composables/channels/useDirectoryError.js new file mode 100644 index 00000000..1bbbaa91 --- /dev/null +++ b/web/src/composables/channels/useDirectoryError.js @@ -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 + } +} diff --git a/web/src/composables/channels/useDirectoryQuery.js b/web/src/composables/channels/useDirectoryQuery.js index 22076bff..e393dd2e 100644 --- a/web/src/composables/channels/useDirectoryQuery.js +++ b/web/src/composables/channels/useDirectoryQuery.js @@ -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, diff --git a/web/src/composables/channels/useMessage.js b/web/src/composables/channels/useMessage.js index 7f6aa294..aef111b7 100644 --- a/web/src/composables/channels/useMessage.js +++ b/web/src/composables/channels/useMessage.js @@ -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, diff --git a/web/src/composables/channels/useOutbox.js b/web/src/composables/channels/useOutbox.js index 9ec281fd..eae024a1 100644 --- a/web/src/composables/channels/useOutbox.js +++ b/web/src/composables/channels/useOutbox.js @@ -6,7 +6,8 @@ * - 所有端点统一返回 { success: true, data: },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 参数) diff --git a/web/src/composables/channels/useSessionError.js b/web/src/composables/channels/useSessionError.js new file mode 100644 index 00000000..42576836 --- /dev/null +++ b/web/src/composables/channels/useSessionError.js @@ -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 + } +} diff --git a/web/src/composables/channels/useSessionMeta.js b/web/src/composables/channels/useSessionMeta.js index 46083bca..841707bf 100644 --- a/web/src/composables/channels/useSessionMeta.js +++ b/web/src/composables/channels/useSessionMeta.js @@ -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) } /** diff --git a/web/src/composables/channels/utils.js b/web/src/composables/channels/utils.js index 14f6b5d0..5920eb82 100644 --- a/web/src/composables/channels/utils.js +++ b/web/src/composables/channels/utils.js @@ -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 diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index feca0800..9bc2d595 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -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', diff --git a/web/src/router/index.js b/web/src/router/index.js index 0febb04c..8f9993fc 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -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 } } ] } diff --git a/web/src/stores/chatUI.js b/web/src/stores/chatUI.js index ad1dab3a..18b3201c 100644 --- a/web/src/stores/chatUI.js +++ b/web/src/stores/chatUI.js @@ -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'] } } ) diff --git a/web/src/utils/qrcode.js b/web/src/utils/qrcode.js new file mode 100644 index 00000000..e1d1b9b6 --- /dev/null +++ b/web/src/utils/qrcode.js @@ -0,0 +1,27 @@ +/** + * 二维码生成工具 + * + * 后端返回的 qr_data_url 通常是渠道登录跳转链接(如微信 liteapp), + * 不能直接作为 使用。本工具将其渲染为 base64 data URL, + * 供前端组件展示二维码图片。 + */ +import QRCode from 'qrcode' + +/** + * 把文本渲染为二维码 base64 data URL。 + * + * @param {string} text - 要编码的文本/URL + * @param {object} options - QRCode.toDataURL 选项 + * @returns {Promise} base64 data URL + */ +export async function generateQrDataUrl(text, options = {}) { + return QRCode.toDataURL(text, { + width: 200, + margin: 2, + color: { + dark: '#000000', + light: '#ffffff' + }, + ...options + }) +}