mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(sessions): paginate admin list, add back-nav, redesign with depth
This commit is contained in:
parent
fe62a98c8f
commit
2d3afa6550
@ -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).
|
||||
*
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '确认删除',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user