- 更新所有 admin API 文件导入路径 - 更新所有 features 中 Vue 文件的导入路径 - 修复 ChatAttachmentImage 组件导入 - 修复 auth 模块部分导入路径 剩余 ~190 个类型错误待修复(主要是缺失的模块)
752 lines
19 KiB
Vue
752 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Close, Picture } from '@element-plus/icons-vue'
|
||
import {
|
||
fetchAdminChat,
|
||
fetchAdminChatMessages,
|
||
fetchAdminChats,
|
||
fetchQuickReplies,
|
||
markAdminChatRead,
|
||
sendAdminChatMessage,
|
||
updateChatRemark,
|
||
type ChatConversation,
|
||
type ChatMessage,
|
||
type QuickReply,
|
||
} from '@/features/chats'
|
||
import { uploadAdminFile } from '@/shared/api/files'
|
||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||
import { formatDateMinute } from '@/utils/time'
|
||
import TransferDialog from './components/TransferDialog.vue'
|
||
import QuickReplyDialog from './components/QuickReplyDialog.vue'
|
||
|
||
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||
|
||
const conversations = ref<ChatConversation[]>([])
|
||
const active = ref<ChatConversation | null>(null)
|
||
const messages = ref<ChatMessage[]>([])
|
||
const loading = ref(false)
|
||
const messageLoading = ref(false)
|
||
const sending = ref(false)
|
||
const uploading = ref(false)
|
||
const content = ref('')
|
||
const attachments = ref<string[]>([])
|
||
const listRef = ref<HTMLElement | null>(null)
|
||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
||
const transferVisible = ref(false)
|
||
const quickReplyVisible = ref(false)
|
||
const quickReplies = ref<QuickReply[]>([])
|
||
const remarkEditing = ref(false)
|
||
const remarkValue = ref('')
|
||
|
||
const activeMembers = computed(() => {
|
||
const participants = active.value?.participants || []
|
||
return participants.map(item => {
|
||
const remark = getParticipantRemark(item)
|
||
const name = remark ? `${remark}(${item.display_name})` : item.display_name
|
||
return `${roleLabel(item.role)}:${name}`
|
||
}).join(' / ')
|
||
})
|
||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||
|
||
function getParticipantRemark(participant: any) {
|
||
if (!active.value) return ''
|
||
const myParticipant = active.value.participants?.find(
|
||
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||
)
|
||
return myParticipant?.remark || ''
|
||
}
|
||
|
||
function handleSSEEvent(event: ChatEvent) {
|
||
if (event.type === 'conversation_updated') {
|
||
loadConversations(false)
|
||
}
|
||
if (event.type === 'new_message' && active.value && event.conversation_id === active.value.id) {
|
||
const msg = event.message
|
||
if (msg && !messages.value.some(m => m.id === msg.id)) {
|
||
messages.value = [...messages.value, {
|
||
id: msg.id,
|
||
conversation_id: msg.conversation_id,
|
||
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
||
sender_id: msg.sender_id,
|
||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||
sender_name: msg.sender_name,
|
||
sender_avatar: '',
|
||
is_self: msg.sender_type === 'admin' && msg.sender_id === currentAdminId,
|
||
content_type: msg.content_type as ChatMessage['content_type'],
|
||
content: msg.content,
|
||
attachment_urls: msg.attachment_urls || [],
|
||
created_at: msg.created_at,
|
||
}]
|
||
nextTick(() => scrollBottom())
|
||
}
|
||
}
|
||
}
|
||
|
||
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
|
||
onEvent(handleSSEEvent)
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadConversations(), loadQuickReplies()])
|
||
})
|
||
|
||
async function loadConversations(showLoading = true) {
|
||
if (showLoading) loading.value = true
|
||
try {
|
||
const res = await fetchAdminChats(1, 100, filter.value)
|
||
conversations.value = res.items
|
||
const first = conversations.value[0]
|
||
if (!active.value && first) {
|
||
await openConversation(first)
|
||
}
|
||
} catch {
|
||
ElMessage.error('会话加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadQuickReplies() {
|
||
try {
|
||
quickReplies.value = await fetchQuickReplies()
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function openConversation(item: ChatConversation) {
|
||
messageLoading.value = true
|
||
try {
|
||
active.value = await fetchAdminChat(item.id)
|
||
await loadMessages(item.id)
|
||
await markAdminChatRead(item.id)
|
||
await loadConversations(false)
|
||
remarkEditing.value = false
|
||
remarkValue.value = ''
|
||
attachments.value = []
|
||
} catch {
|
||
ElMessage.error('会话详情加载失败')
|
||
} finally {
|
||
messageLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadMessages(id: number, scroll = true) {
|
||
const res = await fetchAdminChatMessages(id, 1, 100)
|
||
messages.value = res.items
|
||
if (scroll) {
|
||
await nextTick()
|
||
scrollBottom()
|
||
}
|
||
}
|
||
|
||
async function handleSend() {
|
||
const text = content.value.trim()
|
||
const imageUrls = [...attachments.value]
|
||
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
|
||
sending.value = true
|
||
try {
|
||
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
|
||
appendMessage(sent)
|
||
content.value = ''
|
||
attachments.value = []
|
||
await loadConversations(false)
|
||
} catch {
|
||
ElMessage.error('发送失败')
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
function appendMessage(message: ChatMessage) {
|
||
if (messages.value.some(item => item.id === message.id)) return
|
||
messages.value = [...messages.value, message]
|
||
nextTick(() => scrollBottom())
|
||
}
|
||
|
||
function pickImages() {
|
||
if (uploading.value || attachments.value.length >= 9) return
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
async function handleImageChange(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const files = Array.from(input.files || [])
|
||
input.value = ''
|
||
if (files.length === 0) return
|
||
const slots = 9 - attachments.value.length
|
||
if (slots <= 0) {
|
||
ElMessage.warning('每条消息最多发送 9 张图片')
|
||
return
|
||
}
|
||
uploading.value = true
|
||
try {
|
||
for (const file of files.slice(0, slots)) {
|
||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 25 * 1024 * 1024) {
|
||
ElMessage.warning(`${file.name} 不符合图片规则`)
|
||
continue
|
||
}
|
||
const uploaded = await uploadAdminFile(file, 'chat')
|
||
attachments.value.push(uploaded.url)
|
||
}
|
||
if (files.length > slots) {
|
||
ElMessage.warning('每条消息最多发送 9 张图片')
|
||
}
|
||
} catch {
|
||
ElMessage.error('图片上传失败')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
attachments.value.splice(index, 1)
|
||
}
|
||
|
||
function handleQuickReplySelect(reply: QuickReply) {
|
||
content.value = reply.content
|
||
quickReplyVisible.value = false
|
||
}
|
||
|
||
function handleFilterChange(val: string) {
|
||
filter.value = val as typeof filter.value
|
||
active.value = null
|
||
messages.value = []
|
||
loadConversations()
|
||
}
|
||
|
||
function handleTransferSuccess() {
|
||
loadConversations(false)
|
||
if (active.value) {
|
||
loadMessages(active.value.id, false)
|
||
}
|
||
}
|
||
|
||
async function startEditRemark() {
|
||
if (!active.value) return
|
||
const myParticipant = active.value.participants?.find(
|
||
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||
)
|
||
remarkValue.value = myParticipant?.remark || active.value.title
|
||
remarkEditing.value = true
|
||
}
|
||
|
||
async function saveRemark() {
|
||
if (!active.value) return
|
||
try {
|
||
await updateChatRemark(active.value.id, remarkValue.value)
|
||
ElMessage.success('备注已更新')
|
||
remarkEditing.value = false
|
||
await fetchAdminChat(active.value.id).then(chat => {
|
||
active.value = chat
|
||
})
|
||
await loadConversations(false)
|
||
} catch {
|
||
ElMessage.error('更新备注失败')
|
||
}
|
||
}
|
||
|
||
function scrollBottom() {
|
||
const el = listRef.value
|
||
if (!el) return
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
|
||
function roleLabel(role: string) {
|
||
const map: Record<string, string> = {
|
||
renter: '租客',
|
||
owner: '号主',
|
||
support: '客服',
|
||
customer: '咨询',
|
||
system: '系统',
|
||
}
|
||
return map[role] || '成员'
|
||
}
|
||
|
||
function senderLabel(item: ChatMessage) {
|
||
if (item.sender_type === 'system') return '系统'
|
||
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
|
||
}
|
||
|
||
function getConversationTitle(item: ChatConversation) {
|
||
const myParticipant = item.participants?.find(
|
||
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
|
||
)
|
||
return myParticipant?.remark || item.title
|
||
}
|
||
|
||
function getSupportName(item: ChatConversation) {
|
||
const support = item.participants?.find(p => p.role === 'support')
|
||
return support?.display_name || '未分配'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="admin-page">
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>客服会话</h1>
|
||
<p>处理订单三方沟通和平台咨询</p>
|
||
</div>
|
||
<div class="head-right">
|
||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-workbench">
|
||
<aside class="conversation-pane" v-loading="loading">
|
||
<div class="filter-tabs">
|
||
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
||
<el-radio-button value="mine">我的会话</el-radio-button>
|
||
<el-radio-button value="all">全部</el-radio-button>
|
||
<el-radio-button value="unassigned">未分配</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
<button
|
||
v-for="item in conversations"
|
||
:key="item.id"
|
||
type="button"
|
||
class="conversation-row"
|
||
:class="{ active: active?.id === item.id }"
|
||
@click="openConversation(item)"
|
||
>
|
||
<div class="row-title">
|
||
<strong>{{ getConversationTitle(item) }}</strong>
|
||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||
</div>
|
||
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
|
||
<div class="row-meta">
|
||
<span class="support-name">{{ getSupportName(item) }}</span>
|
||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||
</div>
|
||
</button>
|
||
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
||
</aside>
|
||
|
||
<main class="message-pane">
|
||
<template v-if="active">
|
||
<header class="message-head">
|
||
<div class="head-title">
|
||
<template v-if="remarkEditing">
|
||
<el-input
|
||
v-model="remarkValue"
|
||
size="small"
|
||
style="width: 200px"
|
||
placeholder="输入备注"
|
||
@keyup.enter="saveRemark"
|
||
/>
|
||
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
|
||
<el-button size="small" @click="remarkEditing = false">取消</el-button>
|
||
</template>
|
||
<template v-else>
|
||
<h2>{{ getConversationTitle(active) }} <el-button link size="small" @click="startEditRemark">编辑备注</el-button></h2>
|
||
<p>{{ activeMembers }}</p>
|
||
</template>
|
||
</div>
|
||
<div class="head-actions">
|
||
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
|
||
<el-button size="small">查看订单</el-button>
|
||
</RouterLink>
|
||
</div>
|
||
</header>
|
||
|
||
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
||
<div
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
class="message-row"
|
||
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
|
||
>
|
||
<template v-if="item.sender_type === 'system'">
|
||
<span>{{ item.content }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
|
||
<p v-if="item.content">{{ item.content }}</p>
|
||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||
<ChatAttachmentImage
|
||
v-for="url in item.attachment_urls"
|
||
:key="url"
|
||
:source="url"
|
||
admin
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="composer">
|
||
<div class="composer-tools">
|
||
<input
|
||
ref="fileInputRef"
|
||
class="hidden-file"
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
multiple
|
||
@change="handleImageChange"
|
||
>
|
||
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
||
<el-button size="small" text>快捷回复</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="reply in quickReplies"
|
||
:key="reply.id"
|
||
:command="reply"
|
||
>
|
||
<span class="reply-title">{{ reply.title }}</span>
|
||
<span class="reply-preview">{{ reply.content.slice(0, 30) }}{{ reply.content.length > 30 ? '...' : '' }}</span>
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
|
||
暂无快捷回复
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
|
||
图片
|
||
</el-button>
|
||
</div>
|
||
<div v-if="attachments.length > 0" class="pending-attachments">
|
||
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||
<ChatAttachmentImage :source="url" admin />
|
||
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
|
||
<el-icon :size="14"><Close /></el-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="composer-input">
|
||
<el-input
|
||
v-model="content"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="1000"
|
||
show-word-limit
|
||
placeholder="输入客服回复"
|
||
@keydown.enter.exact.prevent="handleSend"
|
||
/>
|
||
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
||
</div>
|
||
</footer>
|
||
</template>
|
||
<el-empty v-else description="请选择会话" />
|
||
</main>
|
||
</div>
|
||
|
||
<TransferDialog
|
||
v-if="active"
|
||
v-model="transferVisible"
|
||
:conversation-id="active.id"
|
||
@success="handleTransferSuccess"
|
||
/>
|
||
|
||
<QuickReplyDialog
|
||
v-model="quickReplyVisible"
|
||
@success="loadQuickReplies"
|
||
/>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-page {
|
||
display: grid;
|
||
height: 100%;
|
||
min-height: 0;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
flex: none;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.page-head h1 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
}
|
||
|
||
.page-head p {
|
||
margin: 6px 0 0;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.head-right {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chat-workbench {
|
||
display: grid;
|
||
min-height: 0;
|
||
grid-template-columns: 330px minmax(0, 1fr);
|
||
overflow: hidden;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.conversation-pane {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
border-right: 1px solid #e5e7eb;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.filter-tabs {
|
||
padding: 12px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.conversation-row {
|
||
position: relative;
|
||
display: block;
|
||
width: 100%;
|
||
padding: 14px;
|
||
border: 0;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
background: transparent;
|
||
text-align: left;
|
||
}
|
||
|
||
.conversation-row.active {
|
||
background: #eef6ff;
|
||
}
|
||
|
||
.row-title {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.row-title strong {
|
||
min-width: 0;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
color: #111827;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.row-title span {
|
||
flex: none;
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.conversation-row p {
|
||
margin: 8px 24px 0 0;
|
||
overflow: hidden;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.row-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.support-name {
|
||
color: #8a94a6;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.row-meta em {
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 9px;
|
||
background: #ef4444;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
line-height: 18px;
|
||
text-align: center;
|
||
}
|
||
|
||
.message-pane {
|
||
display: grid;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||
}
|
||
|
||
.message-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.head-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.head-title h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.head-title p {
|
||
margin: 6px 0 0;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.head-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.head-actions a {
|
||
text-decoration: none;
|
||
}
|
||
|
||
.message-list {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 18px;
|
||
background: #f3f6fa;
|
||
}
|
||
|
||
.message-row {
|
||
max-width: 70%;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.message-row.self {
|
||
margin-left: auto;
|
||
text-align: right;
|
||
}
|
||
|
||
.message-row.system {
|
||
max-width: none;
|
||
text-align: center;
|
||
}
|
||
|
||
.message-row small {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
color: #8a94a6;
|
||
}
|
||
|
||
.message-row p {
|
||
display: inline-block;
|
||
margin: 0;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: #111827;
|
||
line-height: 1.5;
|
||
text-align: left;
|
||
}
|
||
|
||
.message-row.self p {
|
||
background: #dff5eb;
|
||
}
|
||
|
||
.message-attachments {
|
||
display: grid;
|
||
justify-items: start;
|
||
gap: 6px;
|
||
}
|
||
|
||
.message-row.self .message-attachments {
|
||
justify-items: end;
|
||
}
|
||
|
||
.message-row.system span {
|
||
display: inline-block;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
background: #e5e7eb;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.composer {
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.composer-tools {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 8px 14px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.hidden-file {
|
||
display: none;
|
||
}
|
||
|
||
.pending-attachments {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 10px 14px 0;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.pending-item {
|
||
position: relative;
|
||
flex: none;
|
||
}
|
||
|
||
.pending-item :deep(.chat-image-button) {
|
||
width: 84px;
|
||
height: 84px;
|
||
}
|
||
|
||
.pending-item :deep(.chat-image-button img) {
|
||
height: 84px;
|
||
}
|
||
|
||
.remove-attachment {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
display: grid;
|
||
width: 22px;
|
||
height: 22px;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
background: rgba(17, 24, 39, 0.72);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.composer-input {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 88px;
|
||
gap: 12px;
|
||
align-items: end;
|
||
padding: 14px;
|
||
}
|
||
|
||
.reply-title {
|
||
font-weight: 500;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.reply-preview {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|