feat(channels): 多渠道网关模块批量功能与样式优化

本次提交完成多渠道网关模块多项核心优化:
1. 新增公共样式库与二维码生成工具,统一列表页交互风格
2. 重构分页、选择、错误处理等通用composable,收敛重复逻辑
3. 调整白名单列表默认分页大小为20,优化数据展示密度
4. 新增目录搜索建议、批量选择与导出能力
5. 优化发件箱与会话统计接口适配,补充趋势数据获取
6. 重构消息列表模块,支持URL状态同步与多维度筛选
7. 修复API错误处理逻辑,补充traceId日志便于排查问题
8. 调整侧边栏导航结构与折叠状态管理
This commit is contained in:
Kris 2026-07-07 16:24:35 +08:00
parent 682d851ff5
commit e6e24a91de
23 changed files with 1121 additions and 76 deletions

View File

@ -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
}

View File

@ -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 = {}) =>

View 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);
}

View File

@ -4,6 +4,7 @@
@import './shorts.css';
@import './dashboard.css';
@import './code-highlight.less';
@import './channels-common.less';
:root {
--header-height: 45px;

View File

@ -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' }
}

View File

@ -121,7 +121,7 @@ export function useAllowlistManage() {
// ===== 分页 =====
const currentPage = ref(1)
const pageSize = ref(50)
const pageSize = ref(20)
// ===== 选中项(批量删除用) =====
const selectedRowKeys = ref([])

View File

@ -58,7 +58,7 @@ export function useChannelAccountAllowlist(channelTypeRef, accountIdRef) {
// 分页
const currentPage = ref(1)
const pageSize = ref(50)
const pageSize = ref(20)
// 选中项(批量删除用)
const selectedRowKeys = ref([])

View File

@ -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,

View 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 querypage/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.queryroute 变化不会回弹
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
}
}

View 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
}
}

View File

@ -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)

View File

@ -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)

View 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
}
}

View File

@ -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,

View File

@ -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.1channel_type / start_time / end_time
// 历史筛选API §12.1channel_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.2keyword / channel_type / channel_session_id / start_time / end_time
// 搜索筛选API §12.2keyword / 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,

View File

@ -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 参数

View 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
}
}

View File

@ -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)
}
/**

View File

@ -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

View File

@ -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',

View File

@ -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 }
}
]
}

View File

@ -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
View 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
})
}