feat(chat): 新增后台客服会话工作台

This commit is contained in:
yml2213
2026-10-30 16:53:59 +08:00
parent 030cedb6fd
commit fd95bff747
9 changed files with 906 additions and 1 deletions
@@ -0,0 +1,61 @@
# Phase 2 验收报告(客服会话工作台)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:后台客服会话列表、消息查看、回复、转接、备注、全部已读
> 结论:**页面与路由已落地,tsc/build 通过,只读接口回归 200;发送与转接未在验收中执行**
---
## 1. 页面与路由
- 页面:`frontend/src/features/admin/AdminChatsPage.tsx`
- 路由:`/console-jy8k3m/chats`
- 权限:查看 `chat:view`,发送/转接/备注 `chat:send`
- 样式:`frontend/src/styles/admin-chat.css`(已接入 `styles/global.css`)
## 2. 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/admin/chats` | 会话列表(`filter` / `stage` / `keyword` / 分页) |
| GET | `/api/admin/chats/counts` | 归属与阶段统计 |
| GET | `/api/admin/chats/:id` | 会话详情 |
| GET | `/api/admin/chats/:id/messages` | 消息列表 |
| POST | `/api/admin/chats/:id/messages` | 发送消息 |
| POST | `/api/admin/chats/:id/read` | 标记已读 |
| POST | `/api/admin/chats/read-all` | 全部已读 |
| GET | `/api/admin/chats/support-admins` | 可转接客服 |
| POST | `/api/admin/chats/:id/transfer` | 转接会话 |
| POST | `/api/admin/chats/:id/remark` | 保存后台备注 |
## 3. 能力
- 左侧会话列表:`filter`(全部 / 我负责的)、`stage`(全部 / 待处理 / 未进群 / 交接中 / 租用中 / 售后 / 已结束)、关键字搜索;未读 Badge、待回复标签、订单号标签、最近消息与相对时间,15 秒轮询。
- 右侧会话面板:会话元信息(订单号、订单状态、客服场景)、消息气泡(本人消息右对齐、图片消息内联预览)、8 秒轮询,切换会话自动滚动到底部。
- 回复区:快捷回复下拉插入(复用 `GET /admin/chats/quick-replies`)、文本输入(2000 字上限、Enter 发送 / Shift+Enter 换行)。
- 会话操作:转接客服(下拉选择 + 二次点击确认)、后台备注保存、全部已读。
- 选中未读会话时自动调用已读接口并刷新列表与详情。
## 4. 回归结果
```text
前端 http://localhost:5289
后端 http://127.0.0.1:18089
Cookie /tmp/jysh-admin-cookie2.txt
```
| 检查项 | 状态 |
|---|---|
| `GET /console-jy8k3m/chats` | 200 |
| `GET /api/admin/chats?page=1&page_size=5` | 200 |
| `GET /api/admin/chats/counts` | 200 |
| `GET /api/admin/chats/support-admins` | 200 |
`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `498.83 kB` / gzip `157.03 kB`,无超大包告警。
## 5. 未覆盖
- 发送消息、转接、备注保存、已读均未执行真实写操作(开发库无真实会话)。
- 实时推送依赖 SSE(`/admin/chats/events`),本页采用轮询兜底,未接入 SSE。
- 自动欢迎语与商品群欢迎配置(`/admin/chats/auto-welcome`、`/listing-group-welcome`)尚未做页面。
+1 -1
View File
@@ -414,7 +414,7 @@ hfb_jysh/
- 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`),线下提号(`/pickup`、`/pickup/:id`,权限 `order:pickup`,口径统一为「线下提号」)。
- 待落地:客服会话工作台 `/chats`、主站二维码池 `/qrcode-pool`。
- 待落地:主站二维码池 `/qrcode-pool`、自动欢迎与商品群欢迎配置。
- 分销站点、规则、订单、结算、二维码池、分销用户、公告。
- 公告、邀请返利、推送、追踪链接。
- 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。
+6
View File
@@ -241,6 +241,11 @@ const AdminPickupsPage = lazy(() =>
default: module.AdminPickupsPage,
}))
)
const AdminChatsPage = lazy(() =>
import('@/features/admin/AdminChatsPage').then(module => ({
default: module.AdminChatsPage,
}))
)
const AdminPickupDetailPage = lazy(() =>
import('@/features/admin/AdminPickupDetailPage').then(module => ({
default: module.AdminPickupDetailPage,
@@ -408,6 +413,7 @@ export function AppRouter() {
<Route path="payments" element={<AdminPaymentsPage />} />
<Route path="pickup" element={<AdminPickupsPage />} />
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
<Route path="chats" element={<AdminChatsPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
@@ -0,0 +1,485 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Badge,
Button,
Card,
Empty,
Input,
Select,
Space,
Spin,
Tag,
Typography,
} from 'antd'
import { ReloadOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons'
import {
fetchAdminChatDetail,
fetchAdminChatMessages,
fetchAdminChats,
fetchAdminChatSupportAdmins,
fetchAdminQuickReplies,
markAdminChatRead,
markAllAdminChatsRead,
sendAdminChatMessage,
transferAdminChat,
updateAdminChatRemark,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import type { AdminChatConversation } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatRelativeTime, formatText } from '@/shared/utils/format'
const filterOptions = [
{ value: 'all', label: '全部会话' },
{ value: 'mine', label: '我负责的' },
]
const stageOptions = [
{ value: 'all', label: '全部阶段' },
{ value: 'pending', label: '待处理' },
{ value: 'unjoined', label: '未进群' },
{ value: 'handoff', label: '交接中' },
{ value: 'renting', label: '租用中' },
{ value: 'after_sale', label: '售后' },
{ value: 'ended', label: '已结束' },
]
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function AdminChatsPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [filter, setFilter] = useState('all')
const [stage, setStage] = useState('all')
const [keyword, setKeyword] = useState('')
const [appliedKeyword, setAppliedKeyword] = useState('')
const [selectedId, setSelectedId] = useState<number>()
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [remarkDraft, setRemarkDraft] = useState('')
const [remarkSaving, setRemarkSaving] = useState(false)
const [transferTarget, setTransferTarget] = useState<number>()
const [transferring, setTransferring] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('chat:view'))
const canSend = Boolean(permissions?.includes('*') || permissions?.includes('chat:send'))
const listQuery = useQuery({
queryKey: ['admin-chats', filter, stage, appliedKeyword],
queryFn: () =>
fetchAdminChats({
filter,
stage,
keyword: appliedKeyword || undefined,
page: 1,
page_size: 50,
}),
enabled: canView,
placeholderData: keepPreviousData,
refetchInterval: 15_000,
})
const conversations = listQuery.data?.items ?? []
useEffect(() => {
if (selectedId || conversations.length === 0) return
setSelectedId(conversations[0].id)
}, [conversations, selectedId])
const detailQuery = useQuery({
queryKey: ['admin-chat', selectedId],
queryFn: () => fetchAdminChatDetail(selectedId as number),
enabled: canView && Boolean(selectedId),
})
const messagesQuery = useQuery({
queryKey: ['admin-chat-messages', selectedId],
queryFn: () => fetchAdminChatMessages(selectedId as number, 1, 100),
enabled: canView && Boolean(selectedId),
refetchInterval: 8_000,
placeholderData: keepPreviousData,
})
const quickRepliesQuery = useQuery({
queryKey: ['admin-quick-replies'],
queryFn: fetchAdminQuickReplies,
enabled: canSend,
})
const supportAdminsQuery = useQuery({
queryKey: ['admin-support-admins'],
queryFn: fetchAdminChatSupportAdmins,
enabled: canSend,
})
const messages = messagesQuery.data?.items ?? []
const conversation = detailQuery.data
useEffect(() => {
if (!selectedId) return
setRemarkDraft(conversation?.admin_remark ?? '')
}, [conversation?.admin_remark, selectedId])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length, selectedId])
const selectConversation = async (item: AdminChatConversation) => {
setSelectedId(item.id)
setConstraintReset()
if (item.unread_count > 0) {
try {
await markAdminChatRead(item.id)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
queryClient.invalidateQueries({ queryKey: ['admin-chat', item.id] }),
])
} catch {
// 已读失败不阻塞会话浏览
}
}
}
const setConstraintReset = () => {
setDraft('')
setTransferTarget(undefined)
}
const send = async () => {
if (!selectedId || !draft.trim()) return
setSending(true)
try {
await sendAdminChatMessage(selectedId, { content: draft.trim() })
setDraft('')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-chat-messages', selectedId] }),
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
])
} catch (error) {
message.error(errorText(error))
} finally {
setSending(false)
}
}
const saveRemark = async () => {
if (!selectedId) return
setRemarkSaving(true)
try {
await updateAdminChatRemark(selectedId, remarkDraft.trim())
message.success('备注已保存')
await queryClient.invalidateQueries({ queryKey: ['admin-chat', selectedId] })
} catch (error) {
message.error(errorText(error))
} finally {
setRemarkSaving(false)
}
}
const transfer = async () => {
if (!selectedId || !transferTarget) return
setTransferring(true)
try {
await transferAdminChat(selectedId, transferTarget)
message.success('会话已转接')
setTransferTarget(undefined)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
queryClient.invalidateQueries({ queryKey: ['admin-chat', selectedId] }),
])
} catch (error) {
message.error(errorText(error))
} finally {
setTransferring(false)
}
}
const quickReplyOptions = useMemo(
() =>
(quickRepliesQuery.data ?? []).map(item => ({
value: item.content,
label: item.title,
})),
[quickRepliesQuery.data]
)
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="客服会话" />
<Alert type="warning" showIcon message="当前账号没有聊天查看权限(chat:view)" />
</div>
)
}
return (
<div className="admin-page admin-chat-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 客服"
title="客服会话"
description="按阶段筛选会话、查看用户消息并直接回复,支持转接与备注。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {listQuery.data?.total ?? 0} 个会话</Tag>
<Tag color="gold">
未读 {conversations.reduce((sum, item) => sum + item.unread_count, 0)}
</Tag>
<Tag color="volcano">待回复 {conversations.filter(item => item.needs_reply).length}</Tag>
</Space>
}
extra={
<Space wrap>
<Button
icon={<ReloadOutlined />}
loading={listQuery.isFetching}
onClick={() => void listQuery.refetch()}
>
刷新
</Button>
<Button
onClick={async () => {
try {
await markAllAdminChatsRead()
message.success('已全部标记为已读')
await queryClient.invalidateQueries({ queryKey: ['admin-chats'] })
} catch (error) {
message.error(errorText(error))
}
}}
>
全部已读
</Button>
</Space>
}
/>
<div className="admin-chat-toolbar">
<Select
value={filter}
options={filterOptions}
style={{ width: 140 }}
onChange={value => setFilter(value)}
/>
<Select
value={stage}
options={stageOptions}
style={{ width: 150 }}
onChange={value => setStage(value)}
/>
<Input.Search
allowClear
value={keyword}
placeholder="搜索会话标题 / 订单号"
style={{ width: 280 }}
onChange={event => setKeyword(event.target.value)}
onSearch={value => setAppliedKeyword(value.trim())}
/>
</div>
{listQuery.isError ? (
<Alert
type="error"
showIcon
message="会话列表加载失败"
description={errorText(listQuery.error)}
/>
) : null}
<div className="admin-chat-layout">
<Card className="admin-chat-list" title="会话列表" styles={{ body: { padding: 0 } }}>
{listQuery.isFetching && conversations.length === 0 ? (
<div className="admin-chat-loading">
<Spin />
</div>
) : conversations.length === 0 ? (
<Empty description="没有符合条件的会话" style={{ padding: 32 }} />
) : (
<div className="admin-chat-list__items">
{conversations.map(item => (
<button
key={item.id}
type="button"
className={`admin-chat-item${item.id === selectedId ? ' is-active' : ''}`}
onClick={() => void selectConversation(item)}
>
<div className="admin-chat-item__top">
<strong>{formatText(item.title)}</strong>
<span className="admin-chat-item__time">
{formatRelativeTime(item.last_message_at)}
</span>
</div>
<div className="admin-chat-item__mid">
<Typography.Text type="secondary" ellipsis>
{formatText(item.last_message_preview)}
</Typography.Text>
{item.unread_count > 0 ? <Badge count={item.unread_count} /> : null}
</div>
<Space size={4} wrap>
<Tag bordered={false}>{item.type}</Tag>
{item.latest_order_no ? (
<Tag bordered={false} color="blue">
{item.latest_order_no}
</Tag>
) : null}
{item.needs_reply ? (
<Tag bordered={false} color="volcano">
待回复
</Tag>
) : null}
</Space>
</button>
))}
</div>
)}
</Card>
<Card className="admin-chat-panel" title={conversation ? conversation.title : '会话详情'}>
{!selectedId ? (
<Empty description="请选择左侧会话" />
) : (
<div className="admin-chat-panel__body">
<div className="admin-chat-meta">
<Space size={6} wrap>
{conversation?.latest_order_no ? (
<Tag bordered={false} color="blue">
订单 {conversation.latest_order_no}
</Tag>
) : null}
{conversation?.latest_order_status ? (
<Tag bordered={false}>{conversation.latest_order_status}</Tag>
) : null}
{conversation?.support_scene ? (
<Tag bordered={false}>{conversation.support_scene}</Tag>
) : null}
<span className="admin-chat-meta__time">
{formatDateTime(conversation?.updated_at)}
</span>
</Space>
</div>
<div className="admin-chat-messages">
{messagesQuery.isFetching && messages.length === 0 ? (
<div className="admin-chat-loading">
<Spin />
</div>
) : messages.length === 0 ? (
<Empty description="还没有消息" />
) : (
messages.map(item => (
<div
key={item.id}
className={`admin-chat-bubble${item.sender_type === 'admin' ? ' is-admin' : ''}`}
>
<div className="admin-chat-bubble__head">
<span>{formatText(item.sender_name || item.sender_role)}</span>
<span className="admin-chat-bubble__time">
{formatDateTime(item.created_at)}
</span>
</div>
<div className="admin-chat-bubble__content">
{item.content_type === 'image' && item.attachment_urls?.length ? (
<Space size={6} wrap>
{item.attachment_urls.map(url => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img src={url} alt="会话图片" className="admin-chat-image" />
</a>
))}
</Space>
) : (
formatText(item.content)
)}
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
<div className="admin-chat-composer">
{quickReplyOptions.length ? (
<Select
showSearch
allowClear
placeholder="插入快捷回复"
style={{ width: '100%' }}
options={quickReplyOptions}
value={undefined}
onChange={value => {
if (value) setDraft(current => (current ? `${current}\n${value}` : value))
}}
/>
) : null}
<Input.TextArea
rows={3}
value={draft}
maxLength={2000}
showCount
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
disabled={!canSend}
onChange={event => setDraft(event.target.value)}
onPressEnter={event => {
if (event.shiftKey) return
event.preventDefault()
void send()
}}
/>
<div className="admin-chat-actions">
<Space size={8} wrap>
<Select
allowClear
placeholder="转接客服"
style={{ width: 200 }}
value={transferTarget}
loading={supportAdminsQuery.isFetching}
options={(supportAdminsQuery.data ?? []).map(admin => ({
value: admin.id,
label: `${admin.nickname || admin.username}(${admin.support_status})`,
}))}
onChange={value => setTransferTarget(value)}
/>
<Button
icon={<SwapOutlined />}
disabled={!canSend || !transferTarget}
loading={transferring}
onClick={() => void transfer()}
>
转接
</Button>
<Input
value={remarkDraft}
placeholder="会话备注(仅后台可见)"
style={{ width: 240 }}
onChange={event => setRemarkDraft(event.target.value)}
onPressEnter={() => void saveRemark()}
/>
<Button loading={remarkSaving} onClick={() => void saveRemark()}>
保存备注
</Button>
</Space>
<Button
type="primary"
icon={<SendOutlined />}
loading={sending}
disabled={!canSend || !draft.trim()}
onClick={() => void send()}
>
发送
</Button>
</div>
</div>
</div>
)}
</Card>
</div>
</div>
)
}
+87
View File
@@ -110,6 +110,11 @@ import type {
AdminPickupProcessEvent,
AdminPickupQuery,
AdminPickupShop,
AdminChatConversation,
AdminChatCounts,
AdminChatMessage,
AdminChatMessagePage,
AdminChatPage,
ManualDisbursementPayload,
OperatingExpensePage,
OperatingExpensePayload,
@@ -1554,4 +1559,86 @@ export async function settleAdminPickupFinancialAdjustment(
return data.data
}
export async function fetchAdminChats(query: {
filter?: string
stage?: string
keyword?: string
page?: number
page_size?: number
}) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminChatPage>>>('/admin/chats', {
params: compactParams(query),
})
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function fetchAdminChatCounts(query: {
filter?: string
stage?: string
keyword?: string
}) {
const { data } = await adminClient.get<ApiResponse<AdminChatCounts>>('/admin/chats/counts', {
params: compactParams(query),
})
return data.data
}
export async function fetchAdminChatDetail(id: number) {
const { data } = await adminClient.get<ApiResponse<AdminChatConversation>>(`/admin/chats/${id}`)
return data.data
}
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 50) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminChatMessagePage>>>(
`/admin/chats/${id}/messages`,
{ params: { page, page_size: pageSize } }
)
return {
items: (data.data.items ?? []) as AdminChatMessage[],
total: data.data.total ?? 0,
page: data.data.page ?? page,
page_size: data.data.page_size ?? pageSize,
}
}
export async function sendAdminChatMessage(
id: number,
payload: { content: string; attachment_urls?: string[] }
) {
const { data } = await adminClient.post<ApiResponse<AdminChatMessage>>(
`/admin/chats/${id}/messages`,
payload
)
return data.data
}
export async function markAdminChatRead(id: number) {
await adminClient.post(`/admin/chats/${id}/read`)
}
export async function markAllAdminChatsRead() {
await adminClient.post('/admin/chats/read-all')
}
export async function transferAdminChat(id: number, toAdminId: number) {
await adminClient.post(`/admin/chats/${id}/transfer`, { to_admin_id: toAdminId })
}
export async function updateAdminChatRemark(id: number, remark: string) {
await adminClient.post(`/admin/chats/${id}/remark`, { remark })
}
export async function fetchAdminChatSupportAdmins() {
const { data } = await adminClient.get<
ApiResponse<Array<{ id: number; username?: string; nickname: string; support_status: string }>>
>('/admin/chats/support-admins')
return data.data ?? []
}
+78
View File
@@ -749,6 +749,84 @@ export interface AdminPickupFinancialAdjustment {
created_at: string
}
export interface AdminChatParticipant {
id: number
conversation_id: number
participant_type: string
participant_id: number
role: string
remark: string
display_name: string
avatar_url: string
last_read_at?: string | null
joined_at: string
}
export interface AdminChatConversation {
id: number
order_id?: number | null
listing_id?: number | null
distribution_site_id?: number | null
latest_order_id?: number | null
latest_order_no?: string
latest_order_status?: string
latest_handoff_status?: string
latest_refund_status?: string
type: string
support_scene?: string
title: string
status: string
role: string
admin_remark?: string
participants?: AdminChatParticipant[]
last_message_id?: number | null
last_message_preview: string
last_message_at?: string | null
last_sender_type?: string
last_sender_id?: number
last_sender_role?: string
unread_count: number
needs_reply: boolean
created_at: string
updated_at: string
}
export interface AdminChatPage {
items: AdminChatConversation[]
total: number
page: number
page_size: number
}
export interface AdminChatMessage {
id: number
conversation_id: number
sender_type: string
sender_id: number
sender_role: string
sender_name: string
sender_avatar: string
is_self: boolean
is_read: boolean
content_type: string
content: string
attachment_urls?: string[]
admin_attention_type?: string
created_at: string
}
export interface AdminChatMessagePage {
items: AdminChatMessage[]
total: number
page: number
page_size: number
}
export interface AdminChatCounts {
ownership: Record<string, number>
stages: Record<string, number>
}
export interface AdminAnnouncementQuery {
category?: string
status?: string
+5
View File
@@ -142,6 +142,7 @@ const allNavGroups: NavGroup[] = [
icon: <CustomerServiceOutlined />,
children: [
{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' },
{ key: '/chats', label: '客服会话', permission: 'chat:view' },
{ key: '/manual-entries', label: '手工录单', permission: 'cs_manual_entry:view' },
{ key: '/chat-quick-replies', label: '快捷回复', permission: 'chat:view' },
],
@@ -207,6 +208,10 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
title: '客服群组',
description: '维护客服群与坐席分配,支撑会话分流。',
},
'/chats': {
title: '客服会话',
description: '按阶段筛选会话、回复用户并转接客服。',
},
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
+182
View File
@@ -0,0 +1,182 @@
/* 后台客服会话工作台 */
.admin-chat-page {
display: grid;
gap: 16px;
}
.admin-chat-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.admin-chat-layout {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
min-height: 620px;
}
.admin-chat-list,
.admin-chat-panel {
border-radius: 12px;
overflow: hidden;
}
.admin-chat-list {
max-height: 720px;
}
.admin-chat-list__items {
max-height: 660px;
overflow-y: auto;
}
.admin-chat-item {
display: grid;
gap: 6px;
width: 100%;
padding: 12px 14px;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
.admin-chat-item:hover {
background: var(--paper);
}
.admin-chat-item.is-active {
background: #f2ece0;
box-shadow: inset 3px 0 0 var(--brand-ink);
}
.admin-chat-item__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.admin-chat-item__top strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-chat-item__time {
flex: none;
font-size: 12px;
color: var(--ink-muted);
}
.admin-chat-item__mid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.admin-chat-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.admin-chat-panel__body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
min-height: 560px;
}
.admin-chat-meta {
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.admin-chat-meta__time {
font-size: 12px;
color: var(--ink-muted);
}
.admin-chat-messages {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 460px;
overflow-y: auto;
padding: 4px 2px;
}
.admin-chat-bubble {
align-self: flex-start;
max-width: 78%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-warm);
}
.admin-chat-bubble.is-admin {
align-self: flex-end;
border-color: #d9e6e1;
background: #eaf2ee;
}
.admin-chat-bubble__head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
color: var(--ink-muted);
}
.admin-chat-bubble__content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
.admin-chat-image {
max-width: 200px;
border-radius: 8px;
border: 1px solid var(--line);
}
.admin-chat-composer {
display: grid;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.admin-chat-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 1024px) {
.admin-chat-layout {
grid-template-columns: 1fr;
}
.admin-chat-list {
max-height: 320px;
}
.admin-chat-list__items {
max-height: 260px;
}
}
+1
View File
@@ -10,6 +10,7 @@
@import './admin-login.css';
@import './admin-shell.css';
@import './admin-distribution.css';
@import './admin-chat.css';
@import './mobile-base.css';
@import './mobile-listing.css';
@import './mobile-profile-orders.css';