index.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. <template>
  2. <div class="app-container seat-config-container">
  3. <!-- 主界面:坐席配置列表 -->
  4. <template v-if="!showTicketSystem">
  5. <el-card shadow="never" class="table-header">
  6. <div class="flex-between">
  7. <div class="search-box">
  8. <el-input
  9. v-model="queryParams.seatName"
  10. placeholder="请输入坐席名称"
  11. style="width: 240px"
  12. clearable
  13. @keyup.enter="handleQuery"
  14. >
  15. <template #append>
  16. <el-button :icon="Search" @click="handleQuery" />
  17. </template>
  18. </el-input>
  19. </div>
  20. <div class="btn-group">
  21. <el-button type="primary" :icon="Plus" @click="handleAdd">创建坐席</el-button>
  22. <el-button type="success" :icon="Tickets" @click="showTicketSystem = true">客服工单</el-button>
  23. </div>
  24. </div>
  25. </el-card>
  26. <el-card shadow="never" class="mt-20">
  27. <el-table :data="seatList" v-loading="loading" style="width: 100%">
  28. <el-table-column type="selection" width="55" align="center" />
  29. <el-table-column label="坐席名称" align="center" prop="seatName" />
  30. <el-table-column label="坐席头像" align="center" width="100">
  31. <template #default="scope">
  32. <el-avatar :size="40" :src="scope.row.avatar" />
  33. </template>
  34. </el-table-column>
  35. <el-table-column label="负责模块" align="center" prop="module">
  36. <template #default="scope">
  37. <dict-tag :options="main_agent_module" :value="scope.row.module" />
  38. </template>
  39. </el-table-column>
  40. <el-table-column label="客服姓名" align="left" min-width="200">
  41. <template #default="scope">
  42. <div class="waiter-tag-wrap">
  43. <span v-for="(waiter, index) in scope.row.waiters" :key="index" class="waiter-name">
  44. {{ waiter.nickName || waiter.userName }} ({{ waiter.userId }}){{ index < scope.row.waiters.length - 1 ? '、' : '' }}
  45. </span>
  46. </div>
  47. </template>
  48. </el-table-column>
  49. <el-table-column label="状态" align="center" prop="status">
  50. <template #default="scope">
  51. <el-switch
  52. v-model="scope.row.status"
  53. :active-value="1"
  54. :inactive-value="0"
  55. @change="handleStatusChange(scope.row)"
  56. />
  57. </template>
  58. </el-table-column>
  59. <el-table-column label="操作" align="center" width="150">
  60. <template #default="scope">
  61. <el-button link type="primary" @click="handleUpdate(scope.row)">编辑</el-button>
  62. <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
  63. </template>
  64. </el-table-column>
  65. </el-table>
  66. </el-card>
  67. </template>
  68. <!-- 子界面:客服工单管理系统 (根据图2重构) -->
  69. <div v-else class="ticket-system-container animate__animated animate__fadeIn">
  70. <div class="ticket-header">
  71. <div class="title-row">
  72. <div class="back-btn" @click="showTicketSystem = false">
  73. <el-icon><ArrowLeft /></el-icon> 返回坐席配置
  74. </div>
  75. <h2 class="sys-title">客服工单中心</h2>
  76. </div>
  77. <el-tabs v-model="activeTicketTab" class="ticket-tabs">
  78. <el-tab-pane label="全部状态" name="all" />
  79. <el-tab-pane label="待处理" name="pending" />
  80. <el-tab-pane label="处理中" name="processing" />
  81. <el-tab-pane label="已完成" name="completed" />
  82. <el-tab-pane label="已废弃" name="abandoned" />
  83. </el-tabs>
  84. </div>
  85. <el-card shadow="never" class="filter-card mt-20">
  86. <el-form :inline="true" :model="ticketQuery" class="demo-form-inline">
  87. <el-form-item label="反馈ID">
  88. <el-input v-model="ticketQuery.id" placeholder="如:20991216..." clearable />
  89. </el-form-item>
  90. <el-form-item label="用户ID">
  91. <el-input v-model="ticketQuery.userId" placeholder="搜索用户ID" clearable />
  92. </el-form-item>
  93. <el-form-item label="反馈渠道">
  94. <el-select v-model="ticketQuery.source" placeholder="全部渠道" style="width: 140px" clearable>
  95. <el-option label="小程序" value="小程序" />
  96. <el-option label="商家" value="商家" />
  97. </el-select>
  98. </el-form-item>
  99. <el-form-item label="问题分类">
  100. <el-select v-model="ticketQuery.category" placeholder="全部分类" style="width: 140px" clearable>
  101. <el-option label="这是问题分类1" value="这是问题分类1" />
  102. <el-option label="测评异常" value="测评异常" />
  103. <el-option label="功能咨询" value="功能咨询" />
  104. <el-option label="支付问题" value="支付问题" />
  105. </el-select>
  106. </el-form-item>
  107. <el-form-item>
  108. <el-button type="primary" :icon="Search" @click="handleTicketSearch">查询</el-button>
  109. <el-button @click="resetTicketQuery">重置</el-button>
  110. </el-form-item>
  111. </el-form>
  112. </el-card>
  113. <el-card shadow="never" class="mt-20">
  114. <el-table :data="displayTicketList" style="width: 100%" v-loading="ticketLoading">
  115. <el-table-column prop="id" label="反馈ID" width="140" />
  116. <el-table-column label="用户信息" width="160">
  117. <template #default="scope">
  118. <div class="u-info">
  119. <span class="u-name">{{ scope.row.userName }}</span>
  120. <span class="u-id">ID:{{ scope.row.userId }}</span>
  121. </div>
  122. </template>
  123. </el-table-column>
  124. <el-table-column prop="content" label="反馈内容" min-width="250" show-overflow-tooltip />
  125. <el-table-column label="状态" width="100" align="center">
  126. <template #default="scope">
  127. <el-tag :type="getStatusType(scope.row.status)" effect="dark" size="small">
  128. {{ getStatusLabel(scope.row.status) }}
  129. </el-tag>
  130. </template>
  131. </el-table-column>
  132. <el-table-column prop="source" label="反馈渠道" width="120" align="center" />
  133. <el-table-column prop="category" label="问题分类" width="140" align="center" />
  134. <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
  135. <el-table-column prop="finishTime" label="完结时间" width="180" align="center">
  136. <template #default="scope">{{ scope.row.finishTime || '-' }}</template>
  137. </el-table-column>
  138. <el-table-column label="操作" width="120" fixed="right" align="center">
  139. <template #default="scope">
  140. <el-button
  141. v-if="scope.row.status === 'pending' || scope.row.status === 'processing'"
  142. link
  143. type="primary"
  144. @click="handleTicketDetail(scope.row)"
  145. >处理</el-button>
  146. <el-button
  147. link
  148. type="info"
  149. @click="handleTicketDetail(scope.row)"
  150. >详情</el-button>
  151. </template>
  152. </el-table-column>
  153. </el-table>
  154. <div class="pagination-wrap">
  155. <el-pagination background layout="prev, pager, next, total" :total="displayTicketList.length" />
  156. </div>
  157. </el-card>
  158. </div>
  159. <!-- 工单详情与处理对话框 -->
  160. <el-dialog
  161. v-model="ticketDetailOpen"
  162. title="工单业务处理"
  163. width="1100px"
  164. custom-class="ticket-detail-dialog"
  165. append-to-body
  166. >
  167. <div class="ticket-detail-wrapper">
  168. <!-- 左侧工单快照 -->
  169. <div class="ticket-snapshot">
  170. <div class="snap-title">工单基础信息</div>
  171. <el-descriptions :column="1" border>
  172. <el-descriptions-item label="反馈ID">{{ currentTicket?.id }}</el-descriptions-item>
  173. <el-descriptions-item label="反馈状态">
  174. <el-tag size="small">{{ getStatusLabel(currentTicket?.status) }}</el-tag>
  175. </el-descriptions-item>
  176. <el-descriptions-item label="反馈人">{{ currentTicket?.userName }} ({{ currentTicket?.userId }})</el-descriptions-item>
  177. <el-descriptions-item label="渠道">{{ currentTicket?.source }}</el-descriptions-item>
  178. <el-descriptions-item label="原始反馈内容">
  179. <div class="raw-content">{{ currentTicket?.content }}</div>
  180. </el-descriptions-item>
  181. </el-descriptions>
  182. <div class="snap-title mt-20">处理操作</div>
  183. <el-form label-position="top">
  184. <el-form-item label="流转状态">
  185. <el-radio-group v-model="ticketHandleForm.status" size="small">
  186. <el-radio-button label="processing">处理中</el-radio-button>
  187. <el-radio-button label="completed">已完成</el-radio-button>
  188. <el-radio-button label="abandoned">废弃</el-radio-button>
  189. </el-radio-group>
  190. </el-form-item>
  191. <el-form-item label="快捷回复">
  192. <el-input
  193. type="textarea"
  194. :rows="4"
  195. v-model="ticketHandleForm.reply"
  196. placeholder="请输入处理意见或回复用户的内容..."
  197. />
  198. </el-form-item>
  199. <el-button type="primary" class="full-width" @click="submitTicketHandle">提交处理结果</el-button>
  200. </el-form>
  201. </div>
  202. <!-- 右侧对话追踪流 (模仿图2下方效果) -->
  203. <div class="ticket-chat-tracker">
  204. <div class="tracker-header">沟通过程溯源</div>
  205. <div class="chat-flow-container">
  206. <div
  207. v-for="(chat, index) in ticketChats"
  208. :key="index"
  209. :class="['chat-bubble-row', chat.sender === 'user' ? 'user' : 'waiter']"
  210. >
  211. <el-avatar :size="40" :src="chat.avatar" />
  212. <div class="chat-content-box">
  213. <div class="chat-meta">{{ chat.name }} · {{ chat.time }}</div>
  214. <div class="chat-bubble">{{ chat.content }}</div>
  215. </div>
  216. </div>
  217. </div>
  218. </div>
  219. </div>
  220. </el-dialog>
  221. <!-- 添加或修改坐席对话框 (原有逻辑) -->
  222. <el-dialog :title="title" v-model="open" width="500px" append-to-body>
  223. <el-form ref="seatRef" :model="form" :rules="rules" label-width="100px">
  224. <el-form-item label="坐席名称" prop="seatName">
  225. <el-input v-model="form.seatName" placeholder="请输入坐席名称" />
  226. </el-form-item>
  227. <el-form-item label="坐席头像" prop="avatar">
  228. <div class="avatar-uploader-box" @click="triggerAvatarUpload" :class="{'has-image': !!avatarPreviewUrl}">
  229. <template v-if="avatarPreviewUrl">
  230. <el-image :src="avatarPreviewUrl" class="avatar-preview" fit="cover" />
  231. <div class="avatar-hover-mask">
  232. <el-icon><Picture /></el-icon>
  233. <span>点击更换头像</span>
  234. </div>
  235. </template>
  236. <div v-else class="avatar-placeholder">
  237. <el-icon><Plus /></el-icon>
  238. <div class="placeholder-text">添加头像</div>
  239. </div>
  240. <input type="file" ref="avatarInput" style="display: none" accept="image/*" @change="handleAvatarUpload" />
  241. </div>
  242. </el-form-item>
  243. <el-form-item label="启用状态" prop="status">
  244. <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
  245. </el-form-item>
  246. <el-form-item label="选择客服" prop="waiterIds">
  247. <el-select v-model="form.waiterIds" multiple placeholder="请选择客服" style="width: 100%">
  248. <el-option v-for="item in waiterOptions" :key="item.userId" :label="item.nickName" :value="item.userId" />
  249. </el-select>
  250. </el-form-item>
  251. <el-form-item label="负责模块" prop="module">
  252. <el-select v-model="form.module" placeholder="请选择负责模块" style="width: 100%">
  253. <el-option
  254. v-for="dict in main_agent_module"
  255. :key="dict.value"
  256. :label="dict.label"
  257. :value="dict.value"
  258. />
  259. </el-select>
  260. </el-form-item>
  261. </el-form>
  262. <template #footer>
  263. <div class="dialog-footer">
  264. <el-button @click="open = false">取 消</el-button>
  265. <el-button type="primary" @click="submitForm">确 定</el-button>
  266. </div>
  267. </template>
  268. </el-dialog>
  269. </div>
  270. </template>
  271. <script setup name="SeatConfig">
  272. import { ref, reactive, computed, onMounted, getCurrentInstance } from 'vue';
  273. import { Search, Plus, Tickets, ArrowLeft, UserFilled, Picture } from '@element-plus/icons-vue';
  274. import { ElMessage, ElMessageBox } from 'element-plus';
  275. //import { getSeatList, getSeatDetail, addSeat, updateSeat, deleteSeat, updateSeatStatus, uploadSeatAvatar } from '@/api/chat/seat';
  276. import { getSeatList, getSeatDetail, addSeat, updateSeat, deleteSeat, updateSeatStatus } from '@/api/chat/seat';
  277. import { listUser } from '@/api/system/user';
  278. const { proxy } = getCurrentInstance();
  279. const { main_agent_module } = proxy.useDict("main_agent_module");
  280. // --- 坐席配置逻辑 ---
  281. const loading = ref(false);
  282. const total = ref(0);
  283. const seatList = ref([]);
  284. const title = ref("");
  285. const open = ref(false);
  286. const waiterOptions = ref([]); // 这里存放所有可选的系统用户
  287. const queryParams = reactive({
  288. pageNum: 1,
  289. pageSize: 10,
  290. seatName: undefined,
  291. module: undefined
  292. });
  293. const form = ref({});
  294. const seatRef = ref(null);
  295. const avatarPreviewUrl = ref("");
  296. const rules = {
  297. seatName: [{ required: true, message: "坐席名称不能为空", trigger: "blur" }],
  298. module: [{ required: true, message: "负责模块不能为空", trigger: "change" }]
  299. };
  300. const avatarInput = ref(null);
  301. function triggerAvatarUpload() { avatarInput.value.click(); }
  302. async function handleAvatarUpload(e) {
  303. const file = e.target.files[0];
  304. if (!file) return;
  305. try {
  306. const res = await uploadSeatAvatar(file);
  307. if (res.code === 200) {
  308. // 核心修改 1:存储 ossId 到 avatar 字段,用于提交后端
  309. form.value.avatar = res.data.ossId;
  310. // 核心修改 2:存储 url 用于前端预览
  311. avatarPreviewUrl.value = res.data.url;
  312. ElMessage.success('头像上传成功');
  313. }
  314. } catch (error) {
  315. ElMessage.error('头像上传失败');
  316. }
  317. }
  318. async function getList() {
  319. loading.value = true;
  320. try {
  321. const res = await getSeatList(queryParams);
  322. if (res.code === 200 || res.code === 0) {
  323. // 确保后端返回的是 TableDataInfo 格式
  324. seatList.value = res.rows || [];
  325. total.value = res.total || 0;
  326. }
  327. } catch (error) {
  328. console.error('获取坐席列表失败:', error);
  329. } finally {
  330. loading.value = false;
  331. }
  332. }
  333. /** 查询客服用户列表 */
  334. async function getWaiterOptions() {
  335. try {
  336. // 假设客服用户在某个特定的部门或角色下,这里简单获取所有活动用户
  337. const res = await listUser({ status: '0', pageNum: 1, pageSize: 1000 });
  338. if (res.code === 200) {
  339. waiterOptions.value = res.rows;
  340. }
  341. } catch (error) {
  342. console.error('获取客服列表失败:', error);
  343. }
  344. }
  345. function handleAdd() {
  346. reset();
  347. title.value = "新增坐席";
  348. open.value = true;
  349. }
  350. async function handleUpdate(row) {
  351. reset();
  352. try {
  353. const res = await getSeatDetail(row.id);
  354. if (res.code === 200 || res.code === 0) {
  355. form.value = res.data;
  356. // 设置头像预览(如果后端返回的是URL)
  357. avatarPreviewUrl.value = res.data.avatar;
  358. // 转换关联的客服ID列表供下拉框显示
  359. if (res.data.waiters) {
  360. form.value.waiterIds = res.data.waiters.map(w => w.userId);
  361. }
  362. title.value = "编辑坐席";
  363. open.value = true;
  364. }
  365. } catch (error) {
  366. console.error('获取坐席详情失败:', error);
  367. }
  368. }
  369. function reset() {
  370. form.value = { id: undefined, seatName: undefined, avatar: undefined, status: 1, waiterIds: [], module: undefined };
  371. avatarPreviewUrl.value = "";
  372. }
  373. async function submitForm() {
  374. seatRef.value.validate(async valid => {
  375. if (valid) {
  376. try {
  377. const data = { ...form.value };
  378. if (data.id) {
  379. await updateSeat(data.id, data);
  380. ElMessage.success('修改成功');
  381. } else {
  382. await addSeat(data);
  383. ElMessage.success('新增成功');
  384. }
  385. open.value = false;
  386. getList();
  387. } catch (error) {
  388. console.error('保存坐席失败:', error);
  389. }
  390. }
  391. });
  392. }
  393. function handleDelete(row) {
  394. ElMessageBox.confirm('确认要删除该坐席吗?', '警告', {
  395. confirmButtonText: '确定',
  396. cancelButtonText: '取消',
  397. type: 'warning'
  398. }).then(async () => {
  399. try {
  400. await deleteSeat(row.id);
  401. ElMessage.success('删除成功');
  402. getList();
  403. } catch (error) {
  404. console.error('删除坐席失败:', error);
  405. }
  406. }).catch(() => {});
  407. }
  408. async function handleStatusChange(row) {
  409. try {
  410. await updateSeatStatus(row.id, row.status);
  411. ElMessage.success(row.status === 1 ? '已启用' : '已停用');
  412. } catch (error) {
  413. // 恢复状态
  414. row.status = row.status === 1 ? 0 : 1;
  415. console.error('修改状态失败:', error);
  416. }
  417. }
  418. function handleQuery() {
  419. queryParams.pageNum = 1;
  420. getList();
  421. }
  422. onMounted(() => {
  423. getList();
  424. getWaiterOptions();
  425. });
  426. // --- 新增:客服工单子系统逻辑 ---
  427. const showTicketSystem = ref(false);
  428. const activeTicketTab = ref('all');
  429. const ticketQuery = reactive({ id: '', userId: '', source: '', category: '' });
  430. const ticketDetailOpen = ref(false);
  431. const currentTicket = ref(null);
  432. const ticketHandleForm = reactive({ status: 'processing', reply: '' });
  433. const ticketLoading = ref(false);
  434. const ticketList = ref([
  435. { id: '20991216194', userId: 'ID19451612', userName: '用户张三', content: '这是一段反馈内容,关于这个反馈工单的文字内容,这是反馈内容,反馈文字。', status: 'pending', source: '小程序', category: '这是问题分类1', createTime: '2099-10-22 17:07:25', finishTime: null },
  436. { id: '20991216193', userId: 'ID11679458', userName: '用户李四', content: '反馈内容展示,这里是一段反馈的具体描述,系统报错无法点击。', status: 'processing', source: '商家', category: '测评异常', createTime: '2099-10-22 16:30:10', finishTime: null },
  437. { id: '20991216192', userId: 'ID29416751', userName: '用户王五', content: '咨询关于审计师报名的相关流程。', status: 'completed', source: '小程序', category: '功能咨询', createTime: '2099-10-22 11:15:25', finishTime: '2099-10-24 14:25:07' },
  438. { id: '20991216191', userId: 'ID19167453', userName: '用户六六', content: '我在支付时遇到了问题,钱扣了但是订单没显示。', status: 'abandoned', source: '商家', category: '支付问题', createTime: '2099-10-22 09:12:25', finishTime: '2099-10-22 10:25:07' },
  439. ]);
  440. const displayTicketList = computed(() => {
  441. return ticketList.value.filter(item => {
  442. // 状态筛选
  443. if (activeTicketTab.value !== 'all' && item.status !== activeTicketTab.value) return false;
  444. // 关键字筛选
  445. if (ticketQuery.id && !item.id.includes(ticketQuery.id)) return false;
  446. if (ticketQuery.userId && !item.userId.includes(ticketQuery.userId)) return false;
  447. if (ticketQuery.source && item.source !== ticketQuery.source) return false;
  448. if (ticketQuery.category && item.category !== ticketQuery.category) return false;
  449. return true;
  450. });
  451. });
  452. const handleTicketSearch = () => {
  453. ticketLoading.value = true;
  454. setTimeout(() => {
  455. ticketLoading.value = false;
  456. ElMessage.success('查询成功');
  457. }, 300);
  458. };
  459. const ticketChats = ref([
  460. { sender: 'user', name: '南风未起', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix', content: '你好,我刚咨询的岗位有点问题', time: '18:27:11' },
  461. { sender: 'waiter', name: '系统客服-小张', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Waiter', content: '您好,非常抱歉让您遇到了问题!请问具体是什么情况呢?', time: '18:28:27' },
  462. { sender: 'user', name: '南风未起', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix', content: '看不到岗位信息,无法进行测评', time: '18:28:52' },
  463. ]);
  464. function getStatusType(status) {
  465. const map = { pending: 'danger', processing: 'primary', completed: 'success', abandoned: 'info' };
  466. return map[status] || 'info';
  467. }
  468. function getStatusLabel(status) {
  469. const map = { pending: '待处理', processing: '处理中', completed: '已完成', abandoned: '已废弃' };
  470. return map[status] || '未知';
  471. }
  472. function handleTicketDetail(row) {
  473. currentTicket.value = row;
  474. ticketDetailOpen.value = true;
  475. ticketHandleForm.status = row.status === 'pending' ? 'processing' : row.status;
  476. ticketHandleForm.reply = '';
  477. }
  478. function resetTicketQuery() {
  479. Object.keys(ticketQuery).forEach(key => ticketQuery[key] = '');
  480. handleTicketSearch();
  481. }
  482. function submitTicketHandle() {
  483. ElMessage.success('工单处理意见已提交');
  484. ticketDetailOpen.value = false;
  485. // 此处应更新列表状态
  486. }
  487. </script>
  488. <style scoped lang="scss">
  489. .seat-config-container {
  490. background-color: #f8fafc;
  491. min-height: calc(100vh - 40px);
  492. padding: 24px;
  493. box-sizing: border-box;
  494. .flex-between { display: flex; justify-content: space-between; align-items: center; }
  495. .mt-10 { margin-top: 10px; }
  496. .mt-20 { margin-top: 20px; }
  497. .table-header { border: none; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.04); }
  498. /* 坐席列表特有 */
  499. .waiter-tag-wrap { color: #64748b; font-size: 13px; .waiter-name { display: inline-block; margin-bottom: 2px; } }
  500. .mb-10 { margin-bottom: 10px; }
  501. /* 工单系统样式 (现代化重构) */
  502. .ticket-system-container {
  503. .ticket-header {
  504. background: #fff; padding: 24px 24px 0; border-radius: 12px 12px 0 0; box-shadow: 0 2px 10px rgba(0,0,0,0.02);
  505. .title-row {
  506. display: flex; align-items: center; gap: 20px; margin-bottom: 20px;
  507. .back-btn { display: flex; align-items: center; gap: 6px; font-size: 14px; color: #64748b; cursor: pointer; &:hover { color: #2563eb; } }
  508. .sys-title { margin: 0; font-size: 20px; font-weight: 800; color: #1e293b; }
  509. }
  510. .ticket-tabs { :deep(.el-tabs__item) { font-size: 15px; font-weight: 600; padding: 0 25px; } }
  511. }
  512. .filter-card { border: none; border-radius: 0 0 12px 12px; }
  513. .u-info { display: flex; flex-direction: column; .u-name { font-weight: 700; color: #1e293b; } .u-id { font-size: 11px; color: #94a3b8; margin-top: 2px; } }
  514. .pagination-wrap { display: flex; justify-content: flex-end; padding: 20px 0 0; }
  515. }
  516. /* 通用 */
  517. :deep(.el-card) { border: none; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.04); }
  518. }
  519. /* 独立样式:兼容 append-to-body 弹窗中的上传组件 */
  520. .avatar-uploader-box {
  521. width: 120px; height: 120px; border: 2px dashed #b0b8c1; border-radius: 12px;
  522. background: #fdfdfd; position: relative; cursor: pointer; overflow: hidden;
  523. display: flex; flex-direction: column; align-items: center; justify-content: center;
  524. transition: all 0.3s ease;
  525. margin-top: 5px;
  526. &:hover {
  527. border-color: #2563eb;
  528. background: #eff6ff;
  529. box-shadow: 0 8px 24px rgba(37, 99, 235, 0.1);
  530. transform: translateY(-2px);
  531. .avatar-hover-mask { opacity: 1; }
  532. }
  533. &.has-image { border-style: solid; border-color: #f1f5f9; }
  534. .avatar-preview { width: 100%; height: 100%; object-fit: cover;}
  535. .avatar-placeholder {
  536. display: flex; flex-direction: column; align-items: center; gap: 8px;
  537. color: #64748b;
  538. .el-icon { font-size: 32px; font-weight: 300; }
  539. .placeholder-text { font-size: 13px; font-weight: 600; }
  540. }
  541. .avatar-hover-mask {
  542. position: absolute; inset: 0; background: rgba(15, 23, 42, 0.6); color: #fff;
  543. display: flex; flex-direction: column; align-items: center; justify-content: center;
  544. gap: 6px; font-size: 11px; opacity: 0; transition: 0.2s;
  545. backdrop-filter: blur(2px);
  546. .el-icon { font-size: 22px; }
  547. }
  548. }
  549. /* 工单详情对话框样式 */
  550. .ticket-detail-wrapper {
  551. display: flex; gap: 24px; height: 600px;
  552. .ticket-snapshot {
  553. flex: 1; border-right: 1px solid #f1f5f9; padding-right: 24px; overflow-y: auto;
  554. .snap-title { font-size: 16px; font-weight: 800; color: #1e293b; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; &::before { content: ''; width: 4px; height: 16px; background: #2563eb; border-radius: 2px; margin-right: 10px; } }
  555. .raw-content { background: #f8fafc; padding: 12px; border-radius: 8px; font-size: 13px; color: #475569; line-height: 1.6; }
  556. .full-width { width: 100%; margin-top: 20px; font-weight: 700; height: 40px; }
  557. }
  558. .ticket-chat-tracker {
  559. flex: 1.5; display: flex; flex-direction: column;
  560. .tracker-header { font-size: 16px; font-weight: 800; color: #1e293b; margin-bottom: 20px; }
  561. .chat-flow-container {
  562. flex: 1; background: #f9fafb; border-radius: 12px; padding: 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
  563. .chat-bubble-row {
  564. display: flex; gap: 12px;
  565. &.user { flex-direction: row; .chat-content-box { .chat-bubble { background: #fff; border: 1px solid #eef2f6; border-radius: 2px 16px 16px 16px; } } }
  566. &.waiter { flex-direction: row-reverse; .chat-content-box { align-items: flex-end; .chat-meta { text-align: right; } .chat-bubble { background: #ecfdf5; border: 1px solid #d1fae5; color: #065f46; border-radius: 16px 2px 16px 16px; } } }
  567. .chat-content-box {
  568. display: flex; flex-direction: column; max-width: 80%;
  569. .chat-meta { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
  570. .chat-bubble { padding: 12px 16px; font-size: 14px; line-height: 1.6; box-shadow: 0 4px 6px rgba(0,0,0,0.02); }
  571. }
  572. }
  573. }
  574. }
  575. }
  576. .animate__fadeIn { animation: fadeIn 0.4s; }
  577. @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
  578. </style>