feat: enhance ChatView with improved layout and conversation interactions

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-30 17:11:05 +08:00
parent 9e61163fa2
commit d07f88ba34
2 changed files with 739 additions and 284 deletions

View File

@@ -1,5 +1,24 @@
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import { initialMessages, prompts } from '../data/requests.js'
const initialMessages = [
{
id: 1,
role: 'agent',
text: '我已读取当前报销、发票、行程和制度命中情况。当前建议:优先处理即将超时与高风险单据。'
},
{
id: 2,
role: 'user',
text: '请列出今天最需要关注的风险。'
},
{
id: 3,
role: 'agent',
text: '主要风险包括3 笔单据将在 30 分钟内超时,市场部存在 2 笔高风险差旅报销,另有 1 笔报销缺少酒店入住清单。'
}
]
export const prompts = ['生成审批意见', '列出补件清单', '解释风险原因', '生成运营简报']
export function useChat(activeView) { export function useChat(activeView) {
const messages = ref([...initialMessages]) const messages = ref([...initialMessages])
@@ -10,21 +29,27 @@ export function useChat(activeView) {
function agentReply(text) { function agentReply(text) {
const c = activeCase.value const c = activeCase.value
if (text.includes('出差申请') || text.includes('出差')) if (text.includes('超时') || text.includes('SLA')) {
return '好的,我来帮您处理出差申请。请提供以下信息:\n1. 出发城市和目的地\n2. 出差日期和天数\n3. 出差事由\n4. 预计费用预算' return '当前最紧急的是 3 笔即将超时单据,建议先按剩余处理时长排序,并把缺附件单据转给申请人补齐。'
if (text.includes('机票') || text.includes('飞机')) }
return '我来帮您查询机票信息。请告诉我:\n1. 出发城市 → 目的城市\n2. 出发日期\n3. 偏好的时间段(上午/下午/晚间)\n4. 舱位要求(经济舱/商务舱)' if (text.includes('高风险') || text.includes('风险')) {
if (text.includes('酒店') || text.includes('住宿')) return '高风险主要集中在市场部差旅报销,风险点包括住宿超标、重复发票疑似命中、行程说明缺失。建议人工复核后再通过。'
return '好的,帮您查找合适的酒店。请提供:\n1. 入住城市和区域偏好\n2. 入住和退房日期\n3. 星级/价位要求\n4. 是否需要含早餐' }
if (text.includes('火车票') || text.includes('高铁')) if (text.includes('部门')) {
return '帮您查询火车票。请告诉我:\n1. 出发站 → 到达站\n2. 出行日期\n3. 座位偏好(二等座/一等座/商务座)\n4. 偏好的出发时间段' return '从待处理金额看,销售部与研发中心占比最高;从异常占比看,市场部更需要优先关注。'
if (text.includes('审批意见')) }
return c ? `${c.id} 建议审批意见:费用归属与预算中心匹配,建议保留业务说明后通过。` : '请先选择一份单据再生成审批意见。' if (text.includes('简报')) {
if (text.includes('补件')) return '运营简报:今日待审批 12 单,高风险 4 单,平均审批 5.6hSLA 达成率 96%。建议优先处理差旅报销和即将超时单据。'
return '补件优先级:业务目的说明、行程或客户名单、直属经理确认记录。' }
if (text.includes('差旅政策') || text.includes('政策')) if (text.includes('补件') || text.includes('附件')) {
return '当前差旅政策要点:\n• 住宿标准:一线城市 600 元/晚,二线城市 400 元/晚\n• 机票优先经济舱3 小时以上可申请商务舱\n• 高铁优先二等座4 小时以上可申请一等座\n• 每日餐饮补贴120 元' return '建议补件清单:酒店入住水单、完整行程单、发票原件或验真结果、直属经理确认记录。'
return '好的,我已记录您的需求。请问还需要什么帮助?我还可以帮您查询差旅政策、预订机票酒店火车票等。' }
if (text.includes('审批意见')) {
return c
? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。`
: '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。'
}
return '收到。我可以继续帮你拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
} }
function scrollToBottom() { function scrollToBottom() {
@@ -53,7 +78,7 @@ export function useChat(activeView) {
messages.value.push({ messages.value.push({
id: Date.now(), id: Date.now(),
role: 'agent', role: 'agent',
text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必审批材料。` text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必审批材料。`
}) })
scrollToBottom() scrollToBottom()
} }

View File

@@ -1,153 +1,162 @@
<template> <template>
<section class="view full"> <section class="assistant-view">
<div class="queue-panel"> <div class="assistant-grid">
<DataTable <article class="conversation-panel panel">
:value="documents" <header class="panel-head">
:paginator="true" <h2>今日你可以这样问我</h2>
:rows="pageSize" <button class="text-action" type="button" @click="rotatePrompts">
:rowsPerPageOptions="[5, 10, 20, 50]" <i class="pi pi-refresh"></i>
:totalRecords="documents.length" <span>换一换</span>
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown JumpToPageInput" </button>
currentPageReportTemplate="共 {totalRecords} 条" </header>
stripedRows
size="small" <div class="prompt-grid">
:rowHover="false" <button
tableStyle="min-width: 960px" v-for="prompt in visiblePrompts"
:key="prompt.text"
class="prompt-card"
type="button"
@click="applyPrompt(prompt.text)"
> >
<Column field="id" header="单据编号"> <i :class="prompt.icon"></i>
<template #body="{ data }"> <span>{{ prompt.text }}</span>
<strong>{{ data.id }}</strong>
</template>
</Column>
<Column field="type" header="申请类型">
<template #body="{ data }">
<span class="type-tag" :class="data.typeTag">{{ data.type }}</span>
</template>
</Column>
<Column field="applicant" header="申请人">
<template #body="{ data }">
<strong>{{ data.applicant }}</strong>
<p class="sub-text">{{ data.dept }}</p>
</template>
</Column>
<Column field="destination" header="目的地 / 行程" />
<Column field="date" header="申请日期" sortable />
<Column field="amount" header="金额" sortable>
<template #body="{ data }">
<strong>{{ data.amount }}</strong>
</template>
</Column>
<Column field="status" header="状态">
<template #body="{ data }">
<Tag :value="data.status" :severity="statusSeverity(data.statusClass)" />
</template>
</Column>
<Column field="conclusion" header="AI 结论">
<template #body="{ data }">
<span class="conclusion" :class="data.statusClass">{{ data.conclusion }}</span>
</template>
</Column>
<Column header="操作" style="width: 120px">
<template #body="{ data }">
<div class="row-actions">
<Button label="对话" icon="pi pi-comment" size="small" severity="secondary" rounded @click="openCaseDialog(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
<!-- AI Travel Assistant Dialog -->
<Dialog
v-model:visible="dialogOpen"
:modal="true"
:draggable="false"
:closable="true"
:header="dialogCase ? dialogCase.type + ' · ' + dialogCase.id : '智能差旅助手'"
:style="{ width: '720px' }"
:breakpoints="{ '760px': '95vw' }"
>
<template #header>
<div class="dialog-header-custom">
<div>
<span class="eyebrow">AI Travel Assistant</span>
<h3>{{ dialogCase ? dialogCase.type + ' · ' + dialogCase.id : '智能差旅助手' }}</h3>
<p v-if="dialogCase">{{ dialogCase.applicant }} · {{ dialogCase.destination }} · {{ dialogCase.days }}</p>
</div>
</div>
</template>
<!-- Quick Service Cards (for new request) -->
<div v-if="!dialogCase" class="service-cards">
<button v-for="svc in services" :key="svc.label" class="service-card" @click="applyService(svc)">
<i :class="svc.piIcon" class="svc-icon"></i>
<strong>{{ svc.label }}</strong>
<small>{{ svc.desc }}</small>
</button> </button>
</div> </div>
<!-- Case Summary --> <div ref="localMessageList" class="message-stream" aria-live="polite">
<div v-if="dialogCase" class="review-summary"> <div class="message-row user">
<div class="risk-ring"> <div class="message-bubble">
<strong>{{ dialogCase.status === '已通过' || dialogCase.status === '已完成' ? '96' : '78' }}</strong> <p>今天我最应该关注哪些问题</p>
<span>合规分</span> <time>09:41</time>
</div> </div>
<div> <span class="chat-avatar user-avatar"><i class="pi pi-user"></i></span>
<h4>{{ dialogCase.conclusion }}</h4>
<p>{{ dialogCase.type }} · {{ dialogCase.destination }} · {{ dialogCase.amount }} · {{ dialogCase.days }}天行程</p>
<div class="summary-pills">
<span>{{ dialogCase.date }}</span>
<span>{{ dialogCase.status }}</span>
<span>{{ dialogCase.applicant }}</span>
</div> </div>
<div class="message-row assistant">
<span class="chat-avatar assistant-avatar"><i class="pi pi-sparkles"></i></span>
<div class="message-bubble">
<p>基于当前数据你优先关注以下 3 </p>
<ol>
<li>3 笔单据将在 30 分钟内超时</li>
<li>市场部有 2 笔高风险差旅报销</li>
<li>1 笔报销缺少酒店入住清单建议优先补充</li>
</ol>
<time>09:41</time>
</div> </div>
</div> </div>
<!-- Messages --> <div class="message-row assistant">
<div class="messages" ref="messageList" aria-live="polite"> <span class="chat-avatar assistant-avatar"><i class="pi pi-sparkles"></i></span>
<TransitionGroup name="msg-list"> <div class="message-bubble">
<div v-for="message in messages" :key="message.id" class="message" :class="message.role"> <p>为你生成行动建议</p>
<span>{{ message.role === 'user' ? '你' : '差旅助手' }}</span> <ul class="action-list">
<li><i class="pi pi-exclamation-circle danger"></i><strong>紧急处理</strong>处理即将超时的 3 笔单据避免 SLA 逾期</li>
<li><i class="pi pi-info-circle warning"></i><strong>风险关注</strong>审核市场部高风险差旅报销重点关注差旅与超标费用</li>
<li><i class="pi pi-arrow-circle-up info"></i><strong>信息补齐</strong>提醒申请人补齐酒店入住清单加快审批进度</li>
<li><i class="pi pi-check-circle success"></i><strong>效率优化</strong>当前审批瓶颈在财务审批建议分配或优先处理</li>
</ul>
<time>09:42</time>
</div>
</div>
<div v-for="message in messages" :key="message.id" class="message-row" :class="message.role === 'user' ? 'user' : 'assistant'">
<span v-if="message.role !== 'user'" class="chat-avatar assistant-avatar"><i class="pi pi-sparkles"></i></span>
<div class="message-bubble">
<p>{{ message.text }}</p> <p>{{ message.text }}</p>
</div> </div>
</TransitionGroup> <span v-if="message.role === 'user'" class="chat-avatar user-avatar"><i class="pi pi-user"></i></span>
<div v-if="!messages.length" class="messages-empty">
<p>告诉我您的差旅需求我来帮您安排 </p>
</div> </div>
</div> </div>
<!-- Quick Prompts --> <div class="composer">
<div class="quick-bar"> <textarea
<Button v-for="p in quickPrompts" :key="p" :label="p" size="small" severity="secondary" rounded outlined @click="applyPrompt(p)" /> :value="draft"
rows="3"
placeholder="输入问题,例如:今天的异常报销主要原因是什么?"
@input="emit('draft', $event.target.value)"
@keydown.ctrl.enter.prevent="emit('send')"
></textarea>
<div class="composer-actions">
<div class="input-tools">
<button type="button" aria-label="上传附件" @click="uploadInput?.click()">
<i class="pi pi-paperclip"></i>
</button>
<button type="button" aria-label="上传图片" @click="uploadInput?.click()">
<i class="pi pi-image"></i>
</button>
<button type="button" aria-label="语音输入">
<i class="pi pi-microphone"></i>
</button>
<input ref="uploadInput" class="sr-only" type="file" multiple @change="emit('upload', $event)" />
</div> </div>
<span class="counter">{{ draft.length }}/2000</span>
<button class="send-btn" type="button" aria-label="发送消息" @click="emit('send')">
<i class="pi pi-send"></i>
</button>
</div>
</div>
</article>
<!-- Input --> <aside class="insight-column">
<div class="dialog-input"> <article class="insight-card panel">
<Textarea v-model="localDraft" rows="2" autoResize placeholder="描述您的差旅需求…" @keydown.ctrl.enter.prevent="emit('send')" /> <header>
<div class="input-actions"> <h3>A. AI 重点关注</h3>
<Button label="发送" icon="pi pi-send" size="small" @click="emit('send')" /> <button type="button">查看全部</button>
</header>
<div class="focus-list">
<div v-for="item in focusItems" :key="item.label" class="focus-row">
<span :class="['dot-icon', item.tone]"><i :class="item.icon"></i></span>
<strong>{{ item.label }}</strong>
<span :class="item.tone">{{ item.value }}</span>
</div> </div>
</div> </div>
</Dialog> </article>
<article class="insight-card panel">
<header>
<h3>B. 场景建议</h3>
<button type="button">查看全部 <i class="pi pi-angle-right"></i></button>
</header>
<div class="suggestion-list">
<button v-for="item in suggestions" :key="item.text" type="button" @click="applyPrompt(item.text)">
<i :class="item.icon"></i>
<span>{{ item.text }}</span>
<i class="pi pi-angle-right"></i>
</button>
</div>
</article>
<article class="insight-card panel">
<header>
<h3>C. 快捷分析</h3>
</header>
<div class="analysis-grid">
<button v-for="item in analysisActions" :key="item.text" type="button" @click="applyPrompt(item.text)">
<i :class="item.icon"></i>
<span>{{ item.text }}</span>
</button>
</div>
</article>
<article class="insight-card panel">
<header>
<h3>D. 最近提问 / 常用问题</h3>
<button type="button" @click="rotatePrompts">
<i class="pi pi-refresh"></i>
<span>换一换</span>
</button>
</header>
<ul class="recent-list">
<li v-for="item in recentQuestions" :key="item">{{ item }}</li>
</ul>
</article>
</aside>
</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
const props = defineProps({ const props = defineProps({
documents: { type: Array, required: true }, documents: { type: Array, required: true },
@@ -160,156 +169,577 @@ const props = defineProps({
messageList: { type: Object, default: null } messageList: { type: Object, default: null }
}) })
const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'update:draft', 'selectCase']) const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'])
const dialogOpen = ref(false) const localMessageList = ref(null)
const dialogCase = ref(null) const uploadInput = ref(null)
const pageSize = ref(10) const promptPage = ref(0)
const services = [ const prompts = [
{ label: '出差申请', desc: '提交出差申请单', piIcon: 'pi pi-file', prompt: '我需要提交一份出差申请' }, { icon: 'pi pi-chart-line', text: '今天有哪些关键指标异常?' },
{ label: '预订机票', desc: '查询并预订机票', piIcon: 'pi pi-send', prompt: '帮我预订机票' }, { icon: 'pi pi-file-check', text: '哪些单据最需要优先处理?' },
{ label: '预订酒店', desc: '查询并预订酒店', piIcon: 'pi pi-building', prompt: '帮我预订酒店' }, { icon: 'pi pi-shield', text: '高风险报销主要集中在哪些部门?' },
{ label: '预订火车票', desc: '查询并预订火车票', piIcon: 'pi pi-map-marker', prompt: '帮我预订火车票' } { icon: 'pi pi-clock', text: '本周审批效率相比昨天如何?' },
{ icon: 'pi pi-lightbulb', text: '给我当前报销场景的处理建议' },
{ icon: 'pi pi-building', text: '生成一份运营简报' },
{ icon: 'pi pi-filter', text: '找出即将超时的单据' },
{ icon: 'pi pi-wallet', text: '分析本月预算执行压力' }
] ]
const localDraft = computed({ const visiblePrompts = computed(() => {
get: () => props.draft, const start = (promptPage.value % 2) * 6
set: (val) => emit('update:draft', val) return prompts.slice(start, start + 6).length === 6 ? prompts.slice(start, start + 6) : prompts.slice(0, 6)
}) })
function statusSeverity(cls) { const focusItems = [
if (cls === 'success') return 'success' { icon: 'pi pi-star-fill', tone: 'danger', label: '3 单即将超时', value: '30 分钟内超时' },
if (cls === 'danger') return 'danger' { icon: 'pi pi-exclamation-triangle', tone: 'warning', label: '市场部高风险占比最高', value: '高风险 2 单' },
return 'warn' { icon: 'pi pi-arrow-right-arrow-left', tone: 'info', label: '重复报销风险 1 笔', value: '待核查' },
{ icon: 'pi pi-check-circle', tone: 'success', label: '1 笔缺失附件', value: '待补充' }
]
const suggestions = [
{ icon: 'pi pi-send', text: '优先处理差旅报销(占待审 62%' },
{ icon: 'pi pi-file-plus', text: '先补齐缺失附件再提交审批' },
{ icon: 'pi pi-car', text: '对超标出租车费用要求补充说明' }
]
const analysisActions = [
{ icon: 'pi pi-wave-pulse', text: '异常原因分析' },
{ icon: 'pi pi-building', text: '部门对比' },
{ icon: 'pi pi-shield', text: '风险趋势' },
{ icon: 'pi pi-filter', text: '审批瓶颈' }
]
const recentQuestions = [
'最近 7 天哪些部门的审批时长最长?',
'本月报销金额环比增长最快的是哪个部门?',
'有哪些报销类型的超标率最高?',
'SLA 未达成的主要原因是什么?'
]
function rotatePrompts() {
promptPage.value += 1
} }
function openCaseDialog(doc) { function applyPrompt(text) {
dialogCase.value = doc emit('draft', text)
emit('selectCase', doc)
dialogOpen.value = true
} }
function applyService(svc) { watch(
emit('draft', svc.prompt) () => props.messages.length,
} () => {
nextTick(() => {
function applyPrompt(p) { localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' })
emit('draft', p) })
} }
)
</script> </script>
<style scoped> <style scoped>
/* ── List View ──────────────────────────────────── */ .assistant-view {
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; } height: 100%;
.view.full { width: 100%; } min-height: 0;
.queue-panel { display: grid;
border: 1px solid var(--line); border-radius: var(--radius); animation: fadeUp 240ms var(--ease) both;
background: var(--surface); overflow: hidden;
} }
.sub-text { margin-top: 3px; color: var(--muted); font-size: 11px; } .assistant-grid {
min-height: 0;
/* Type tags */ display: grid;
.type-tag { grid-template-columns: minmax(0, 1.5fr) minmax(360px, .95fr);
display: inline-flex; align-items: center; gap: 4px; gap: 16px;
padding: 3px 10px; border-radius: 999px; align-items: stretch;
font-size: 12px; font-weight: 750; white-space: nowrap;
}
.type-tag.travel { background: var(--primary-soft); color: var(--primary); }
.type-tag.flight { background: #eef3ff; color: #335cff; }
.type-tag.hotel { background: var(--warning-soft); color: var(--warning); }
.type-tag.train { background: var(--success-soft); color: var(--success); }
.conclusion { color: var(--muted); font-size: 12px; }
.conclusion.success { color: var(--success); }
.conclusion.warning { color: var(--warning); }
.conclusion.danger { color: var(--danger); }
.row-actions { display: flex; gap: 6px; }
/* ── Dialog Custom Header ───────────────────────── */
.dialog-header-custom h3 { margin: 4px 0 0; color: var(--ink); font-size: 20px; }
.dialog-header-custom p { margin-top: 4px; color: var(--muted); font-size: 12px; }
/* ── Service Cards ──────────────────────────────── */
.service-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
margin-bottom: 16px;
}
.service-card {
display: grid; gap: 6px; place-items: center; text-align: center;
padding: 16px 8px; border: 1px solid var(--line); border-radius: var(--radius);
background: #fff; cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.service-card:hover { border-color: rgba(51,92,255,.28); box-shadow: 0 8px 24px rgba(51,92,255,.08); }
.svc-icon { font-size: 22px; color: var(--primary); }
.service-card strong { color: var(--ink); font-size: 13px; }
.service-card small { color: var(--muted); font-size: 11px; }
/* ── Summary ────────────────────────────────────── */
.review-summary {
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px;
padding: 14px 0; margin-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.risk-ring {
width: 68px; aspect-ratio: 1; display: grid; place-items: center;
border-radius: 50%;
background: radial-gradient(circle,#fff 0 55%,transparent 56%), conic-gradient(var(--success) 0 82%, #e4e7ec 82% 100%);
}
.risk-ring strong { color: var(--ink); font-size: 20px; line-height: 1; }
.risk-ring span { color: var(--muted); font-size: 10px; }
.review-summary h4 { color: var(--ink); font-size: 14px; }
.review-summary p { margin-top: 3px; color: var(--muted); font-size: 13px; }
.summary-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.summary-pills span {
min-height: 24px; display: inline-flex; align-items: center;
padding: 0 8px; border-radius: 999px;
background: var(--primary-soft); color: var(--primary); font-size: 11px; font-weight: 750;
} }
/* ── Messages ───────────────────────────────────── */ .conversation-panel {
.messages { min-height: 0;
min-height: 160px; max-height: 360px; overflow-y: auto; display: grid;
display: grid; align-content: start; gap: 12px; grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 14px 0; background: linear-gradient(180deg, #fbfcff, #f6f8fb); padding: 20px;
border-radius: var(--radius); margin-bottom: 12px; overflow: hidden;
} }
.message { max-width: 82%; display: grid; gap: 4px; }
.message.user { justify-self: end; } .panel-head,
.message span { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .insight-card header {
.message p { display: flex;
padding: 12px 14px; border: 1px solid var(--line); align-items: center;
border-radius: 14px 14px 14px 4px; background: #fff; justify-content: space-between;
font-size: 13px; line-height: 1.6; white-space: pre-line; gap: 12px;
} }
.message.user p {
border-color: transparent; border-radius: 14px 14px 4px 14px; .panel-head h2,
background: linear-gradient(135deg, var(--primary), #2446d8); .insight-card h3 {
color: #0f172a;
font-size: 17px;
font-weight: 800;
}
.text-action,
.insight-card header button {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
color: #64748b;
font-size: 13px;
}
.text-action:hover,
.insight-card header button:hover {
color: #10b981;
}
.prompt-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.prompt-card {
min-height: 52px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px 14px;
border: 1px solid #dce5ef;
border-radius: 8px;
background: #fff;
color: #334155;
text-align: left;
transition: border-color 180ms ease, box-shadow 180ms ease, color 180ms ease;
}
.prompt-card:hover {
border-color: rgba(16,185,129,.36);
box-shadow: 0 8px 20px rgba(15,23,42,.06);
color: #0f9f78;
}
.prompt-card i {
color: #10b981;
font-size: 19px;
}
.prompt-card:nth-child(4n) i,
.prompt-card:nth-child(5n) i {
color: #f59e0b;
}
.prompt-card:nth-child(3n) i {
color: #3b82f6;
}
.prompt-card span {
min-width: 0;
font-size: 14px;
font-weight: 650;
line-height: 1.35;
}
.message-stream {
min-height: 0;
display: grid;
align-content: start;
gap: 14px;
margin-top: 18px;
padding: 2px 8px 12px 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: #b6c4d5 #f1f5f9;
}
.message-stream::-webkit-scrollbar,
.insight-column::-webkit-scrollbar {
width: 8px;
}
.message-stream::-webkit-scrollbar-track,
.insight-column::-webkit-scrollbar-track {
border-radius: 999px;
background: #f1f5f9;
}
.message-stream::-webkit-scrollbar-thumb,
.insight-column::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #b6c4d5;
}
.message-row {
display: grid;
gap: 10px;
align-items: start;
}
.message-row.assistant {
grid-template-columns: 38px minmax(0, .74fr);
}
.message-row.user {
grid-template-columns: minmax(0, .4fr) 38px;
justify-content: end;
}
.chat-avatar {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 17px;
}
.assistant-avatar {
background: #dff7ee;
color: #0f9f78;
}
.user-avatar {
background: #dff7ee;
color: #0f9f78;
}
.message-bubble {
position: relative;
padding: 14px 16px;
border: 1px solid #dce5ef;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 14px;
line-height: 1.65;
}
.message-row.user .message-bubble {
background: linear-gradient(135deg, rgba(16,185,129,.14), rgba(16,185,129,.07));
border-color: rgba(16,185,129,.22);
}
.message-bubble p,
.message-bubble ol,
.message-bubble ul {
margin: 0;
}
.message-bubble ol {
padding-left: 18px;
color: #0f9f78;
}
.action-list {
display: grid;
gap: 4px;
padding-left: 0;
list-style: none;
}
.action-list li {
display: flex;
gap: 8px;
}
.action-list i {
margin-top: 4px;
}
.danger { color: #ef4444; }
.warning { color: #f59e0b; }
.info { color: #3b82f6; }
.success { color: #10b981; }
.message-bubble time {
float: right;
margin-left: 14px;
color: #64748b;
font-size: 12px;
}
.composer {
min-height: 94px;
display: grid;
grid-template-rows: minmax(48px, 1fr) auto;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.composer textarea {
width: 100%;
min-height: 52px;
resize: none;
border: 0;
padding: 14px 16px 6px;
color: #0f172a;
font-size: 14px;
line-height: 1.5;
}
.composer textarea::placeholder {
color: #91a2b5;
}
.composer textarea:focus {
outline: none;
}
.composer-actions {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 0 10px 10px 12px;
}
.input-tools {
display: flex;
gap: 8px;
}
.input-tools button,
.send-btn {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
}
.input-tools button {
background: transparent;
color: #42526b;
font-size: 18px;
}
.input-tools button:hover {
background: #f1f5f9;
color: #0f9f78;
}
.counter {
color: #64748b;
font-size: 13px;
}
.send-btn {
background: #10b981;
color: #fff; color: #fff;
font-size: 17px;
} }
.messages-empty { display: grid; place-items: center; padding: 32px; }
.messages-empty p { color: var(--muted); font-size: 13px; }
.msg-list-enter-active { transition: opacity 200ms var(--ease), transform 200ms var(--ease); }
.msg-list-leave-active { transition: opacity 120ms ease; }
.msg-list-enter-from { opacity: 0; transform: translateY(6px); }
.msg-list-leave-to { opacity: 0; }
/* ── Quick Prompts ──────────────────────────────── */ .send-btn:hover {
.quick-bar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } background: #0ea672;
}
/* ── Input ──────────────────────────────────────── */
.dialog-input { .insight-column {
display: grid; grid-template-columns: minmax(0, 1fr) auto; min-height: 0;
gap: 10px; align-items: start; max-height: 100%;
display: grid;
align-content: start;
gap: 12px;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: #b6c4d5 #f1f5f9;
}
.insight-card {
padding: 18px 20px;
}
.focus-list {
display: grid;
margin-top: 12px;
}
.focus-row {
min-height: 34px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border-bottom: 1px solid #eef2f7;
color: #334155;
font-size: 14px;
}
.focus-row:last-child {
border-bottom: 0;
}
.dot-icon {
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: 999px;
color: #fff;
font-size: 10px;
}
.dot-icon.danger { background: #ef4444; color: #fff; }
.dot-icon.warning { background: #f59e0b; color: #fff; }
.dot-icon.info { background: #3b82f6; color: #fff; }
.dot-icon.success { background: #10b981; color: #fff; }
.focus-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.focus-row > span:last-child {
font-size: 13px;
font-weight: 700;
}
.suggestion-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.suggestion-list button {
min-height: 42px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #334155;
text-align: left;
}
.suggestion-list button:hover,
.analysis-grid button:hover {
border-color: rgba(16,185,129,.32);
color: #0f9f78;
}
.suggestion-list i:first-child {
color: #3b82f6;
}
.suggestion-list button:nth-child(2) i:first-child {
color: #10b981;
}
.suggestion-list button:nth-child(3) i:first-child {
color: #f59e0b;
}
.analysis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.analysis-grid button {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.analysis-grid i {
color: #10b981;
font-size: 18px;
}
.recent-list {
display: grid;
gap: 10px;
margin: 14px 0 0;
padding: 0;
list-style: none;
}
.recent-list li {
position: relative;
padding-left: 16px;
color: #334155;
font-size: 14px;
}
.recent-list li::before {
content: "";
position: absolute;
left: 0;
top: .62em;
width: 7px;
height: 7px;
border-radius: 999px;
background: #10b981;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1280px) {
.assistant-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
.insight-column {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-height: none;
overflow: visible;
}
} }
.input-actions { display: grid; gap: 6px; padding-top: 2px; }
@media (max-width: 760px) { @media (max-width: 760px) {
.dialog-input { grid-template-columns: 1fr; } .prompt-grid,
.input-actions { display: flex; } .insight-column,
.service-cards { grid-template-columns: repeat(2, 1fr); } .analysis-grid {
grid-template-columns: 1fr;
}
.conversation-panel,
.insight-card {
padding: 16px;
}
.message-row.assistant,
.message-row.user {
grid-template-columns: 34px minmax(0, 1fr);
}
.message-row.user {
grid-template-columns: minmax(0, 1fr) 34px;
}
} }
</style> </style>