feat(sessions): paginate admin list, add back-nav, redesign with depth

This commit is contained in:
matevip 2026-05-20 20:58:05 +08:00
parent fe62a98c8f
commit 2d3afa6550
6 changed files with 425 additions and 51 deletions

View File

@ -147,6 +147,76 @@ public class ConversationService {
.collect(Collectors.toList());
}
/**
* Paginated variant used by the Sessions admin page.
*
* <p>Mirrors {@link #listConversations(String, Long)}'s filtering (current
* user + system rows, top-level only, optional workspace) and adds a
* {@code keyword} match against title / conversationId. The keyword is
* case-insensitive and treated as a substring.
*
* <p>会话管理页使用的分页查询 {@link #listConversations(String, Long)}
* 的基础上增加 title / conversationId 模糊匹配
*/
public com.baomidou.mybatisplus.core.metadata.IPage<ConversationVO> pageConversations(
String username, Long workspaceId, int page, int size, String keyword) {
if (page < 1) page = 1;
if (size < 1 || size > 200) size = 20;
com.baomidou.mybatisplus.extension.plugins.pagination.Page<ConversationEntity> pager =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
.isNull(ConversationEntity::getParentConversationId)
.orderByDesc(ConversationEntity::getPinned)
.orderByDesc(ConversationEntity::getLastActiveTime);
if (workspaceId != null) {
wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId);
}
if (keyword != null && !keyword.isBlank()) {
String kw = keyword.trim();
wrapper.and(w -> w
.like(ConversationEntity::getTitle, kw)
.or()
.like(ConversationEntity::getConversationId, kw));
}
com.baomidou.mybatisplus.core.metadata.IPage<ConversationEntity> entityPage =
conversationMapper.selectPage(pager, wrapper);
List<ConversationEntity> entities = entityPage.getRecords();
Map<Long, AgentEntity> agentMap;
if (entities.isEmpty()) {
agentMap = Map.of();
} else {
List<Long> agentIds = entities.stream()
.filter(e -> e.getAgentId() != null)
.map(ConversationEntity::getAgentId)
.distinct()
.collect(Collectors.toList());
agentMap = agentIds.isEmpty()
? Map.of()
: agentMapper.selectBatchIds(agentIds).stream()
.collect(Collectors.toMap(AgentEntity::getId, a -> a));
}
com.baomidou.mybatisplus.core.metadata.IPage<ConversationVO> voPage =
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<ConversationVO>(
entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entities.stream()
.map(entity -> {
AgentEntity agent = entity.getAgentId() != null
? agentMap.get(entity.getAgentId())
: null;
String agentName = agent != null ? agent.getName() : null;
String agentIcon = agent != null ? agent.getIcon() : null;
return ConversationVO.from(entity, agentName, agentIcon);
})
.collect(Collectors.toList()));
return voPage;
}
/**
* Get-or-create conversation (backward-compat overload, defaults to workspace 1).
*

View File

@ -41,6 +41,22 @@ public class ConversationController {
return R.ok(conversationService.listConversations(username, workspaceId));
}
/**
* 分页查询会话列表用于会话管理页
* <p>会话管理页可能跨多个 IM 渠道单页全量返回会拖慢首屏
*/
@Operation(summary = "分页查询会话列表")
@GetMapping("/page")
public R<com.baomidou.mybatisplus.core.metadata.IPage<ConversationVO>> page(
Authentication auth,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword) {
String username = auth != null ? auth.getName() : "anonymous";
return R.ok(conversationService.pageConversations(username, workspaceId, page, size, keyword));
}
/**
* 获取指定会话的消息历史支持分页
* <p>

View File

@ -154,6 +154,13 @@ export const chatApi = {
// ==================== Conversation ====================
export const conversationApi = {
list: () => http.get('/conversations'),
/**
* Paginated list used by the Sessions admin page. Keyword matches title
* or conversationId server-side; ChatConsole's left panel still uses the
* non-paginated list() because it shows a per-agent rolling history.
*/
page: (params: { page?: number; size?: number; keyword?: string }) =>
http.get('/conversations/page', { params }),
listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) =>
http.get(`/conversations/${conversationId}/messages`, { params }),
getStatus: (conversationId: string) =>

View File

@ -1736,6 +1736,7 @@ export default {
sessions: {
title: 'Sessions',
desc: 'View and manage all conversation sessions',
back: 'Back',
search: 'Search sessions...',
columns: {
session: 'Session',
@ -1752,6 +1753,9 @@ export default {
closed: 'Closed',
},
empty: 'No sessions found',
emptyHeading: 'No sessions yet',
emptyDesc: 'Head over to Chat to start a conversation — every session you have will show up here.',
emptyCta: 'Start chatting',
loadFailed: 'Failed to load sessions',
deleteConfirm: 'Are you sure you want to delete this session?',
deleteTitle: 'Confirm Delete',

View File

@ -1628,6 +1628,7 @@ export default {
sessions: {
title: '会话管理',
desc: '查看和管理所有会话',
back: '返回',
search: '搜索会话...',
columns: {
session: '会话',
@ -1644,6 +1645,9 @@ export default {
closed: '已关闭',
},
empty: '暂无会话',
emptyHeading: '还没有任何会话',
emptyDesc: '去对话页开始一次交流,所有的会话都会在这里汇总',
emptyCta: '开始对话',
loadFailed: '加载会话列表失败',
deleteConfirm: '确定要删除这个会话吗?',
deleteTitle: '确认删除',

View File

@ -1,9 +1,16 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('sessions.title') }}</h1>
<p class="page-desc">{{ t('sessions.desc') }}</p>
<div class="page-header-title">
<button class="back-btn" :title="t('sessions.back')" @click="goBack">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<div class="title-block">
<h1 class="page-title">{{ t('sessions.title') }}</h1>
<p class="page-desc">{{ t('sessions.desc') }}</p>
</div>
</div>
<div class="header-actions">
<div class="search-box">
@ -17,6 +24,7 @@
<!-- 会话列表 -->
<div class="sessions-table-wrap">
<div class="sessions-table-scroll">
<table class="sessions-table">
<thead>
<tr>
@ -31,7 +39,7 @@
</tr>
</thead>
<tbody>
<tr v-for="session in filteredSessions" :key="session.conversationId" class="session-row">
<tr v-for="session in sessions" :key="session.conversationId" class="session-row">
<td>
<div class="session-info">
<div class="session-title">{{ session.title }}</div>
@ -98,22 +106,45 @@
</div>
</td>
</tr>
<tr v-if="filteredSessions.length === 0">
<tr v-if="sessions.length === 0">
<td colspan="8" class="empty-row">
<div class="empty-state">
<span class="empty-icon">💬</span>
<p>{{ t('sessions.empty') }}</p>
<div class="empty-icon-ring">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3 class="empty-heading">{{ t('sessions.emptyHeading') }}</h3>
<p class="empty-desc">{{ t('sessions.emptyDesc') }}</p>
<button class="empty-cta" @click="router.push('/chat')">
{{ t('sessions.emptyCta') }}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="total > 0" class="sessions-pager-row">
<McPagination
v-model:page="currentPage"
v-model:size="pageSize"
:total="total"
:sizes="[10, 20, 50, 100]"
@change="onPagerChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
@ -123,11 +154,15 @@ import { channelIconUrl, sourceLabel } from '@/utils/channelSource'
import type { Conversation, ProviderInfo } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import ModelSelector from '@/components/chat/ModelSelector.vue'
import McPagination from '@/components/common/McPagination.vue'
const router = useRouter()
const { t } = useI18n()
const sessions = ref<Conversation[]>([])
const searchText = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// Per-conversation model selection state (closes #183). Loaded once on mount,
// not per-row, because providers don't change during a session-list view.
@ -135,24 +170,43 @@ const providers = ref<ProviderInfo[]>([])
const modelEditingId = ref<string | null>(null)
const modelSavingId = ref<string | null>(null)
const filteredSessions = computed(() => {
if (!searchText.value) return sessions.value
const q = searchText.value.toLowerCase()
return sessions.value.filter(s =>
s.title?.toLowerCase().includes(q) ||
s.conversationId?.toLowerCase().includes(q)
)
})
onMounted(async () => {
await loadSessions()
await loadProviders()
})
// Debounce keyword changes so we don't fire a request on every keystroke.
// 300ms matches the existing search-on-type rhythm used elsewhere in the UI.
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchText, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadSessions()
}, 300)
})
function onPagerChange() {
loadSessions()
}
function goBack() {
// Prefer history when we arrived from ChatConsole's overflow menu, fall
// back to /chat for direct deep links.
if (window.history.length > 1) router.back()
else router.push('/chat')
}
async function loadSessions() {
try {
const res: any = await conversationApi.list()
sessions.value = res.data || []
const res: any = await conversationApi.page({
page: currentPage.value,
size: pageSize.value,
keyword: searchText.value || undefined,
})
const body = res.data || {}
sessions.value = body.records || []
total.value = Number(body.total) || 0
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
}
@ -269,45 +323,264 @@ function formatTime(time?: string) {
</script>
<style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.header-actions { display: flex; gap: 10px; }
.search-box { display: flex; align-items: center; gap: 8px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 8px; padding: 8px 12px; }
/* ==========================================================================
Sessions admin three-depth design system
--------------------------------------------------------------------------
Layer 1 (canvas): page-container atmospheric gradient anchored on the
page edges so the eye has somewhere to breathe.
Layer 2 (surface): sessions-table-wrap the floating "data island",
lifted with multi-step soft shadow.
Layer 3 (accent): primary-tinted CTAs, the title rail, focus glows.
All colors flow through theme tokens (--mc-bg / --mc-primary-bg /
--mc-shadow-soft ) so light/dark switch is automatic.
========================================================================== */
.page-container {
position: relative;
height: 100%;
overflow-y: auto;
padding: 32px 32px 40px;
background:
radial-gradient(ellipse 90% 60% at 0% 0%, var(--mc-primary-bg), transparent 55%),
radial-gradient(ellipse 70% 50% at 100% 100%, var(--mc-accent-soft), transparent 60%),
var(--mc-bg);
}
/* ============================== Header ============================== */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.page-header-title { display: flex; align-items: flex-start; gap: 14px; }
.back-btn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 50%;
color: var(--mc-text-secondary);
cursor: pointer;
transition: transform 0.15s ease, background 0.15s, border-color 0.15s, color 0.15s;
margin-top: 2px;
padding: 0;
box-shadow: var(--mc-shadow-soft);
}
.back-btn:hover {
background: var(--mc-bg-elevated);
border-color: var(--mc-primary);
color: var(--mc-primary);
transform: translateX(-2px);
}
/* Vertical accent rail anchors the title block to the page gives the
eye a physical "start here" point instead of letting it float. */
.title-block {
position: relative;
padding-left: 14px;
}
.title-block::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 6px;
width: 3px;
border-radius: 2px;
background: linear-gradient(180deg, var(--mc-primary), var(--mc-accent));
}
.page-title {
font-size: 22px;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0 0 4px;
letter-spacing: -0.01em;
}
.page-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0; }
/* ============================== Search ============================== */
.header-actions { display: flex; gap: 10px; align-items: center; }
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 10px;
padding: 9px 14px;
box-shadow: var(--mc-shadow-soft);
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-box:focus-within {
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px var(--mc-primary-bg), var(--mc-shadow-soft);
}
.search-box svg { color: var(--mc-text-tertiary); flex-shrink: 0; }
.search-input { border: none; outline: none; font-size: 14px; color: var(--mc-text-primary); background: transparent; width: 200px; }
.sessions-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; }
.sessions-table { width: 100%; border-collapse: collapse; }
.sessions-table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); }
.session-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
.session-row:hover { background: var(--mc-bg-sunken); }
.search-input {
border: none;
outline: none;
font-size: 14px;
color: var(--mc-text-primary);
background: transparent;
width: 220px;
}
/* ============================== Table card (Surface) ============================== */
.sessions-table-wrap {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--mc-shadow-medium);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
/* Inner scroller takes the horizontal overflow so the outer wrap can keep
`overflow: hidden` that's what makes the rounded corners actually clip
the th background instead of letting it bleed into the top corners. */
.sessions-table-scroll { overflow-x: auto; }
.sessions-table { width: 100%; min-width: 920px; border-collapse: collapse; }
/* Default to no vertical-stacking of CJK; specific cells opt back into wrap
if they truly need to (session title / id is the only multi-line cell). */
.sessions-table th,
.sessions-table td { white-space: nowrap; }
.sessions-table td:first-child { white-space: normal; }
.sessions-table th {
padding: 14px 18px;
text-align: left;
font-size: 11px;
font-weight: 600;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
background: var(--mc-bg-muted);
border-bottom: 1px solid var(--mc-border);
}
.session-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.12s; }
.session-row:hover { background: var(--mc-bg-muted); }
.session-row:last-child { border-bottom: none; }
.sessions-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
.session-info {}
.session-title { font-weight: 500; color: var(--mc-text-primary); margin-bottom: 2px; }
.session-id { font-size: 11px; color: var(--mc-text-tertiary); font-family: monospace; }
.source-cell { display: flex; align-items: center; gap: 6px; }
.sessions-table td { padding: 16px 18px; font-size: 14px; color: var(--mc-text-primary); }
.session-title { font-weight: 600; color: var(--mc-text-primary); margin-bottom: 3px; letter-spacing: -0.005em; }
.session-id { font-size: 11px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.source-cell { display: flex; align-items: center; gap: 8px; }
.source-icon { display: flex; align-items: center; flex-shrink: 0; }
.source-name { font-size: 12px; color: var(--mc-text-secondary); white-space: nowrap; }
.agent-cell { display: flex; align-items: center; gap: 6px; }
.agent-cell { display: flex; align-items: center; gap: 8px; }
.agent-icon-sm { font-size: 16px; }
.msg-count { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
/* Model chip: collapsed state for the per-conversation model selector
(issue #183). Click opens the inline ModelSelector dropdown. */
.model-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 6px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; max-width: 220px; transition: all 0.15s; }
.model-chip:hover { background: var(--mc-bg-elevated); border-color: var(--mc-primary); color: var(--mc-text-primary); }
.msg-count {
background: var(--mc-bg-muted);
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
}
/* Model chip — collapsed state for the per-conversation model selector (#183) */
.model-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
border-radius: 8px;
font-size: 12px;
color: var(--mc-text-secondary);
cursor: pointer;
max-width: 220px;
transition: all 0.15s;
}
.model-chip:hover {
background: var(--mc-bg-elevated);
border-color: var(--mc-primary);
color: var(--mc-text-primary);
}
.model-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-badge { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.status-badge { padding: 4px 11px; border-radius: 20px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; }
.status-active { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-closed { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.time-cell { color: var(--mc-text-tertiary); font-size: 13px; }
.row-actions { display: flex; gap: 4px; }
.row-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
.row-btn:hover { background: var(--mc-bg-sunken); }
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
.empty-icon { font-size: 32px; }
.empty-state p { font-size: 14px; margin: 0; }
.row-actions { display: flex; gap: 6px; }
.row-btn {
width: 30px;
height: 30px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--mc-text-secondary);
transition: all 0.15s;
}
.row-btn:hover { background: var(--mc-bg-muted); border-color: var(--mc-border); color: var(--mc-primary); }
.row-btn.danger:hover { background: var(--mc-danger-bg, rgba(220, 38, 38, 0.1)); border-color: var(--mc-danger, #dc2626); color: var(--mc-danger, #dc2626); }
/* ============================== Empty state (CTA moment) ============================== */
.empty-row { padding: 64px 24px !important; background: transparent; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--mc-text-secondary);
max-width: 380px;
margin: 0 auto;
text-align: center;
}
.empty-icon-ring {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mc-primary-bg);
color: var(--mc-primary);
margin-bottom: 4px;
box-shadow: 0 0 0 8px var(--mc-bg-muted);
}
.empty-heading {
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
letter-spacing: -0.005em;
}
.empty-desc { font-size: 13px; line-height: 1.6; color: var(--mc-text-tertiary); margin: 0; }
.empty-cta {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
background: var(--mc-primary);
color: var(--mc-text-inverse);
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
box-shadow: var(--mc-shadow-soft);
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.empty-cta:hover {
background: var(--mc-primary-hover);
transform: translateY(-1px);
box-shadow: var(--mc-shadow-medium);
}
.empty-cta:active { transform: translateY(0); }
/* ============================== Pagination ============================== */
.sessions-pager-row { margin-top: 20px; display: flex; justify-content: flex-end; }
</style>