Files
X-Financial/web/src/components/business/PersonalWorkbench.vue
caoxiaozhu d0e946cf47 feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保
存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导
航,完善文档中心状态筛选和详情提示,报销创建和审批详情
页优化会话管理和费用明细交互,新增助手应用服务和预设动
作工具函数,补充单元测试覆盖。
2026-05-25 13:35:39 +08:00

1163 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="workbench">
<PanelHead
v-if="showHeader"
eyebrow="Personal Workspace"
title="个人工作台"
note="把今天要处理的待办、报销进度和制度更新集中到一个入口。"
/>
<article class="panel assistant-hero">
<div class="assistant-visual" aria-hidden="true">
<span class="assistant-glow"></span>
<img class="assistant-image" :src="robotAssistant" alt="" />
</div>
<div class="assistant-copy">
<h3>{{ assistantGreetingName }}描述您想做的事AI 会直接帮您处理</h3>
<p>我会自动识别您的意图协助完成费用申请报销查询和制度问答等业务工作耐心把事情推进到可执行的下一步</p>
<div class="assistant-input">
<input
ref="fileInputRef"
class="assistant-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<textarea
v-model="assistantDraft"
rows="1"
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
@keydown.enter.prevent="handleWorkbenchEnter"
/>
</div>
<div v-if="selectedFiles.length" class="assistant-file-strip">
<span class="assistant-file-note">已带入 {{ selectedFiles.length }} 份附件</span>
<span v-for="file in selectedFiles" :key="file.name" class="assistant-file-chip">{{ file.name }}</span>
<button type="button" class="assistant-file-clear" @click="clearSelectedFiles">清空</button>
</div>
<div class="assistant-tools">
<button type="button" class="ghost-action" :disabled="Boolean(pendingAction)" @click="triggerFileUpload">
<i class="mdi mdi-upload-outline"></i>
<span>上传票据</span>
</button>
<button
type="button"
class="hero-action"
:disabled="Boolean(pendingAction)"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction === 'expense' ? 'mdi mdi-loading mdi-spin' : expenseActionIcon"></i>
<span>{{ pendingAction === 'expense' ? '处理中...' : expenseActionLabel }}</span>
</button>
</div>
</div>
</article>
<div class="workbench-grid">
<article class="panel list-panel">
<div class="section-head">
<div class="title-with-badge">
<h3>报销待办</h3>
<span class="alert-badge">{{ todoAlertCount }}</span>
</div>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="list-body">
<div v-for="item in todoItems" :key="item.title" class="todo-row">
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<div class="todo-copy">
<strong>{{ item.title }}</strong>
<p class="todo-advice">
<span class="todo-advice-label">{{ item.tipLabel }}</span>
<span class="todo-advice-text">{{ item.suggestion }}</span>
</p>
</div>
<button type="button" class="row-action" @click="emit('open-assistant')">{{ item.action }}</button>
</div>
</div>
</article>
<article class="panel list-panel">
<div class="section-head">
<div class="title-with-badge">
<h3>报销进度</h3>
<span class="alert-badge">{{ progressAlertCount }}</span>
</div>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="list-body">
<div v-for="item in progressItems" :key="item.id" class="progress-row">
<WorkbenchListIcon
:icon-key="item.iconKey"
:color="item.color"
:accent="item.accent"
/>
<div class="todo-copy progress-copy">
<strong>{{ item.title }}</strong>
<p>提交时间{{ item.date }}</p>
</div>
<strong class="progress-amount">{{ item.amount }}</strong>
<span class="progress-status" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</article>
</div>
<article class="panel policy-panel">
<div class="section-head">
<h3>最新报销制度</h3>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="policy-table">
<div class="policy-head policy-row">
<span class="policy-title-cell">制度名称</span>
<span class="policy-summary-cell">摘要</span>
<span class="policy-date-cell">发布日期</span>
</div>
<div v-for="item in policyItems" :key="item.name" class="policy-row">
<strong class="policy-title-cell">{{ item.name }}</strong>
<span class="policy-summary-cell">{{ item.summary }}</span>
<span class="policy-date-cell">{{ item.date }}</span>
</div>
</div>
</article>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import robotAssistant from '../../assets/robot-helper.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
assistantModalOpen: { type: Boolean, default: false }
})
const emit = defineEmits(['open-assistant'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan'))
const assistantGreetingName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
function mergeSelectedFiles(existingFiles, incomingFiles) {
const nextFiles = []
const seen = new Set()
for (const file of existingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let overflowCount = 0
for (const file of incomingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
if (nextFiles.length >= MAX_ATTACHMENTS) {
overflowCount += 1
continue
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
overflowCount
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function buildAssistantPayload() {
return {
prompt: assistantDraft.value.trim(),
source: 'workbench',
files: Array.from(selectedFiles.value)
}
}
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
}
function emitAssistant(payload) {
emit('open-assistant', payload)
resetWorkbenchDraft()
}
async function loadLatestConversation() {
const payload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return payload?.found ? payload.conversation || null : null
}
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return
}
handleExpenseConversationAction()
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleWorkbenchFilesChange(event) {
const mergeResult = mergeSelectedFiles(selectedFiles.value, Array.from(event.target.files ?? []))
selectedFiles.value = mergeResult.files
if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
console.warn('Failed to refresh latest expense conversation:', error)
latestExpenseConversation.value = null
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
async function handleExpenseConversationAction() {
if (pendingAction.value) {
return
}
const nextPayload = buildAssistantPayload()
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
pendingAction.value = 'expense'
try {
await clearKnowledgeHistoryBeforeExpense()
const conversation = await loadLatestConversation()
latestExpenseConversation.value = conversation
emitAssistant({
...nextPayload,
conversation
})
} catch (error) {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
pendingAction.value = ''
}
}
const todoItems = [
{
title: '业务招待报销建议补参与人员',
tipLabel: 'AI 建议',
suggestion: '补充客户单位、客户人数、我方陪同人员',
action: '去补充',
iconKey: 'hospitality',
color: '#0d9668',
accent: '#6ee7b7'
},
{
title: '差旅报销单待提交',
tipLabel: 'AI 建议',
suggestion: '补齐出发交通,可直接生成报销单',
action: '继续填写',
iconKey: 'travelDraft',
color: '#15803d',
accent: '#86efac'
},
{
title: '有 5 张票据未关联报销单',
tipLabel: 'AI 建议',
suggestion: '其中 3 张疑似交通费,可合并生成交通报销',
action: '去整理',
iconKey: 'receipts',
color: '#2563eb',
accent: '#93c5fd'
}
]
const todoAlertCount = todoItems.length
const progressItems = [
{
id: 'travel',
title: '差旅报销',
amount: '¥3,280',
date: '2026-05-03',
status: '主管审批中',
tone: 'success',
iconKey: 'flight',
color: '#0d9668',
accent: '#6ee7b7'
},
{
id: 'transport',
title: '交通报销',
amount: '¥126',
date: '2026-05-02',
status: '财务复核中',
tone: 'info',
iconKey: 'transport',
color: '#2563eb',
accent: '#93c5fd'
},
{
id: 'office',
title: '办公采购',
amount: '¥458',
date: '2026-05-01',
status: '已到账',
tone: 'mint',
iconKey: 'procurement',
color: '#059669',
accent: '#a7f3d0'
}
]
const progressAlertCount = progressItems.filter((item) => item.status !== '已到账').length
const policyItems = [
{
name: '差旅报销管理办法2026版',
summary: '更新住宿标准与交通等级规则',
date: '2026-05-04'
},
{
name: '业务招待费用报销规范',
summary: '明确参与人员与事由填写要求',
date: '2026-05-02'
},
{
name: '交通费用报销细则',
summary: '补充网约车与停车费报销说明',
date: '2026-04-28'
},
{
name: '票据与附件提交规范通知',
summary: '统一附件命名与上传要求',
date: '2026-04-25'
}
]
onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
</script>
<style scoped>
.workbench {
min-width: 0;
display: grid;
gap: 16px;
padding-bottom: 10px;
}
.assistant-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 228px minmax(0, 1fr);
gap: 18px;
padding: 20px 24px 20px 18px;
border: 1px solid rgba(16, 185, 129, 0.12);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
radial-gradient(circle at right 20%, rgba(59, 130, 246, 0.07), transparent 28%),
linear-gradient(135deg, #f7fffb 0%, #ffffff 48%, #f5fbff 100%);
}
.assistant-hero::before,
.assistant-hero::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(16, 185, 129, 0.06);
pointer-events: none;
}
.assistant-hero::before {
right: -48px;
bottom: -58px;
width: 220px;
height: 220px;
}
.assistant-hero::after {
right: 92px;
top: -44px;
width: 140px;
height: 140px;
}
.assistant-visual {
position: relative;
min-height: 196px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0 0 10px 8px;
}
.assistant-visual::before {
content: "";
position: absolute;
inset: auto auto -78px -58px;
width: 264px;
height: 228px;
border-radius: 50%;
background: radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.92) 0%, rgba(220, 252, 231, 0.84) 58%, rgba(220, 252, 231, 0) 100%);
pointer-events: none;
}
.assistant-visual::after {
content: "";
position: absolute;
left: 52px;
bottom: 18px;
width: 132px;
height: 18px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.14);
filter: blur(12px);
pointer-events: none;
}
.assistant-glow {
position: absolute;
left: 24px;
bottom: 22px;
width: 176px;
height: 176px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.9) 58%, rgba(236, 253, 245, 0) 100%);
box-shadow: 0 24px 48px rgba(16, 185, 129, 0.12);
pointer-events: none;
}
.assistant-image {
position: relative;
z-index: 1;
width: 184px;
max-width: 100%;
height: auto;
object-fit: contain;
object-position: left bottom;
filter: drop-shadow(0 22px 28px rgba(15, 23, 42, 0.16));
}
.assistant-copy {
position: relative;
z-index: 1;
display: grid;
gap: 10px;
align-content: center;
}
.assistant-copy h3 {
color: #0f172a;
font-size: 26px;
line-height: 1.25;
font-weight: 800;
}
.assistant-copy p {
max-width: 760px;
color: #5b6b83;
font-size: 14px;
line-height: 1.6;
}
.assistant-input {
display: flex;
align-items: center;
min-height: 48px;
padding: 4px 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.assistant-file-input {
display: none;
}
.assistant-input textarea {
min-width: 0;
flex: 1;
height: 22px;
min-height: 22px;
max-height: 22px;
resize: none;
border: 0;
padding: 1px 0;
background: transparent;
color: #0f172a;
font-size: 15px;
line-height: 22px;
overflow: hidden;
}
.assistant-input textarea::placeholder {
color: #94a3b8;
}
.assistant-input textarea:focus {
outline: none;
}
.hero-action,
.secondary-action,
.ghost-action,
.row-action,
.link-action,
.row-link {
border: 0;
background: transparent;
}
.hero-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
}
.hero-action .mdi,
.secondary-action .mdi,
.ghost-action .mdi {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.hero-action span,
.secondary-action span,
.ghost-action span {
display: inline-flex;
align-items: center;
line-height: 1;
}
.assistant-tools {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.assistant-file-strip {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.assistant-file-note,
.assistant-file-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.assistant-file-note {
background: rgba(16, 185, 129, 0.1);
color: #047857;
}
.assistant-file-chip {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(255, 255, 255, 0.9);
color: #475569;
}
.assistant-file-clear {
border: 0;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.ghost-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 250, 247, 0.88));
color: #0f766e;
font-size: 14px;
font-weight: 700;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 6px 14px rgba(15, 118, 110, 0.06);
}
.ghost-action .mdi {
color: #10b981;
}
.secondary-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(59, 130, 246, 0.18);
border-radius: 10px;
background: linear-gradient(180deg, rgba(244, 249, 255, 0.96), rgba(234, 244, 255, 0.9));
color: #1d4ed8;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 6px 14px rgba(37, 99, 235, 0.08);
}
.secondary-action .mdi {
color: #2563eb;
}
.hero-action:disabled,
.secondary-action:disabled,
.ghost-action:disabled {
cursor: not-allowed;
opacity: 0.68;
box-shadow: none;
}
.workbench-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.list-panel,
.policy-panel {
padding: 20px 22px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head h3 {
color: #0f172a;
font-size: 17px;
font-weight: 700;
}
.title-with-badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.alert-badge {
min-width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 12px;
font-weight: 800;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.link-action {
display: inline-flex;
align-items: center;
gap: 4px;
color: #10b981;
font-size: 14px;
font-weight: 700;
}
.list-body {
display: grid;
}
.todo-row,
.progress-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 14px 0;
border-top: 1px solid #edf2f7;
}
.todo-row:first-child,
.progress-row:first-child {
padding-top: 4px;
border-top: 0;
}
.todo-copy {
min-width: 0;
}
.todo-copy strong {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.todo-copy p {
margin-top: 4px;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
}
.todo-advice {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.todo-advice-label {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.todo-advice-text {
color: #64748b;
}
.row-action {
height: 38px;
padding: 0 16px;
border: 1px solid rgba(16, 185, 129, 0.36);
border-radius: 10px;
color: #10b981;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.progress-row {
grid-template-columns: 56px minmax(0, 1fr) minmax(84px, auto) minmax(104px, auto);
gap: 14px 16px;
}
.progress-copy strong {
margin-bottom: 2px;
}
.progress-amount {
color: #0f172a;
font-size: 20px;
font-weight: 800;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.progress-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 104px;
min-height: 34px;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
justify-self: end;
}
.progress-status.success,
.policy-status.success {
background: #eafaf2;
color: #16935f;
}
.progress-status.info,
.policy-status.info {
background: #eff6ff;
color: #3b82f6;
}
.progress-status.mint {
background: #edfdf5;
color: #10b981;
}
.policy-table {
border: 1px solid #e7edf5;
border-radius: 12px;
overflow: hidden;
}
.policy-row {
display: grid;
grid-template-columns: 2.2fr 2.4fr 1fr;
gap: 16px;
align-items: center;
min-height: 56px;
padding: 0 18px;
border-top: 1px solid #edf2f7;
}
.policy-head {
min-height: 44px;
background: #f8fbff;
color: #64748b;
font-size: 12px;
font-weight: 800;
border-top: 0;
}
.policy-row strong,
.policy-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.policy-row strong {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.policy-row span {
color: #64748b;
font-size: 14px;
}
.policy-title-cell,
.policy-summary-cell {
justify-self: stretch;
text-align: left;
}
.policy-date-cell {
justify-self: center;
text-align: center;
}
@media (max-width: 1320px) {
.assistant-copy h3 {
font-size: 24px;
}
.policy-row {
grid-template-columns: 1.8fr 1.8fr 1fr;
}
}
@media (max-width: 1440px) {
.workbench {
gap: 14px;
}
.assistant-hero {
gap: 16px;
padding: 18px 20px 18px 16px;
}
.assistant-copy h3 {
font-size: 24px;
}
.assistant-visual {
min-height: 184px;
}
.assistant-image {
width: 172px;
}
.workbench-grid {
gap: 16px;
}
.list-panel,
.policy-panel {
padding: 18px 20px;
}
.policy-row {
min-height: 52px;
padding: 0 16px;
}
}
@media (max-width: 1080px) {
.assistant-hero {
grid-template-columns: 1fr;
gap: 8px;
}
.assistant-visual {
min-height: 188px;
justify-content: center;
padding: 0 0 8px;
}
.assistant-visual::before,
.assistant-visual::after,
.assistant-glow {
left: 50%;
transform: translateX(-50%);
}
.assistant-visual::before {
inset: auto auto -82px 50%;
}
.assistant-image {
width: 176px;
}
.workbench-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.assistant-hero,
.list-panel,
.policy-panel {
padding: 18px;
}
.assistant-input {
flex-direction: column;
align-items: stretch;
padding: 14px;
}
.assistant-visual {
min-height: 160px;
}
.assistant-glow {
width: 148px;
height: 148px;
}
.assistant-image {
width: 150px;
}
.assistant-input textarea {
height: 40px;
min-height: 40px;
max-height: 40px;
line-height: 1.5;
}
.hero-action,
.secondary-action,
.ghost-action,
.row-action {
width: 100%;
justify-content: center;
}
.assistant-file-chip {
max-width: 100%;
}
.todo-row,
.progress-row {
grid-template-columns: 56px minmax(0, 1fr);
}
.progress-amount {
grid-column: 2;
text-align: left;
font-size: 18px;
}
.row-action,
.progress-status {
grid-column: 2;
justify-self: start;
}
.policy-table {
border: 0;
border-radius: 0;
}
.policy-head {
display: none;
}
.policy-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 0;
border-top: 1px solid #edf2f7;
}
.policy-row strong,
.policy-row span {
white-space: normal;
}
}
</style>