index.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889
  1. <!-- @Author: Antigravity -->
  2. <template>
  3. <div class="employee-page">
  4. <transition :enter-active-class="proxy?.animate.searchAnimate.enter"
  5. :leave-active-class="proxy?.animate.searchAnimate.leave">
  6. <div v-show="showSearch" class="search">
  7. <el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="85px">
  8. <el-form-item label="姓名" prop="name">
  9. <el-input v-model="queryParams.name" placeholder="请输入姓名" clearable @keyup.enter="handleQuery" />
  10. </el-form-item>
  11. <el-form-item label="手机号" prop="phone">
  12. <el-input v-model="queryParams.phone" placeholder="请输入手机号" clearable @keyup.enter="handleQuery" />
  13. </el-form-item>
  14. <el-form-item>
  15. <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
  16. <el-button icon="Refresh" @click="resetQuery">重置</el-button>
  17. </el-form-item>
  18. </el-form>
  19. </div>
  20. </transition>
  21. <el-card shadow="never">
  22. <template #header>
  23. <el-row :gutter="10" class="mb8">
  24. <el-col :span="1.5">
  25. <el-button v-hasPermi="['employee:employee:add']" type="primary" icon="Plus"
  26. @click="handleAdd">新增</el-button>
  27. </el-col>
  28. <right-toolbar v-model:show-search="showSearch" @query-table="getList"></right-toolbar>
  29. </el-row>
  30. </template>
  31. <el-table v-loading="loading" :data="employeeList" @selection-change="handleSelectionChange">
  32. <el-table-column type="selection" width="55" align="center" />
  33. <el-table-column label="序号" align="center" width="70" type="index" />
  34. <el-table-column label="姓名" align="center" prop="name" min-width="120" />
  35. <el-table-column label="头像" align="center" width="80">
  36. <template #default="scope">
  37. <el-avatar :size="36" :src="scope.row.avatarUrl" icon="UserFilled" />
  38. </template>
  39. </el-table-column>
  40. <el-table-column label="手机" align="center" prop="phone" width="140" />
  41. <el-table-column label="注册时间" align="center" prop="createTime" width="170" />
  42. <el-table-column label="状态" align="center" width="100">
  43. <template #default="scope">
  44. <el-switch v-if="checkPermi(['employee:employee:changeStatus'])" v-model="scope.row._statusActive"
  45. :loading="scope.row._statusLoading" inline-prompt @change="handleStatusChange(scope.row)" />
  46. <el-tag v-else :type="scope.row.status === '0' ? 'danger' : 'success'">
  47. {{ scope.row.status === '0' ? '禁用' : '启用' }}
  48. </el-tag>
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
  52. <template #default="scope">
  53. <el-button v-hasPermi="['employee:employee:auth']" link type="success"
  54. @click="handleAuth(scope.row)">授权</el-button>
  55. <el-button v-hasPermi="['employee:employee:query']" link type="primary"
  56. @click="handleDetail(scope.row)">详情</el-button>
  57. <el-button v-hasPermi="['employee:employee:resetPassword']" link type="warning"
  58. @click="handleResetPwd(scope.row)">重置密码</el-button>
  59. </template>
  60. </el-table-column>
  61. </el-table>
  62. <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
  63. v-model:limit="queryParams.pageSize" @pagination="getList" />
  64. </el-card>
  65. <!-- 授权客户对话框 -->
  66. <SelectClientDialog v-model="authDialog.visible" v-model:selected-clients="selectedAuthClients" title="授权客户"
  67. selected-label="已授权" placeholder-text="输入客户名称搜索并添加授权客户" confirm-text="确认授权" :search-results="authSearchResults"
  68. :search-loading="authSearchLoading" :confirm-loading="buttonLoading" :searched="authSearched"
  69. @search="handleAuthSearch" @confirm="confirmAuth" />
  70. <!-- 新增员工对话框 -->
  71. <el-dialog v-model="addDialog.visible" title="新增员工" width="520px" append-to-body @close="resetAddForm"
  72. class="add-employee-dialog">
  73. <div class="add-form-wrapper">
  74. <!-- 头像区 -->
  75. <div class="add-avatar-area">
  76. <el-upload class="add-avatar-uploader" :action="uploadUrl" :headers="uploadHeaders" :show-file-list="false"
  77. :on-success="handleAddAvatarSuccess" :before-upload="beforeAddAvatarUpload">
  78. <div class="add-avatar-mask">
  79. <img v-if="addForm.avatarUrl" :src="addForm.avatarUrl" class="add-avatar-img" />
  80. <el-icon v-else class="add-avatar-placeholder">
  81. <UserFilled />
  82. </el-icon>
  83. <div class="add-avatar-overlay">
  84. <el-icon>
  85. <Camera />
  86. </el-icon>
  87. <span>更换头像</span>
  88. </div>
  89. </div>
  90. </el-upload>
  91. <p class="add-avatar-tip">点击上传员工头像</p>
  92. </div>
  93. <el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-position="top" class="add-form">
  94. <el-form-item label="姓名" prop="name">
  95. <el-input v-model="addForm.name" placeholder="请输入员工姓名" clearable maxlength="30" size="large" />
  96. </el-form-item>
  97. <el-form-item label="电话(登录账号)" prop="phone">
  98. <el-input v-model="addForm.phone" placeholder="请输入手机号作为登录账号" clearable maxlength="11" size="large" />
  99. </el-form-item>
  100. <el-form-item label="授权客户">
  101. <div class="add-auth-wrap">
  102. <div v-if="addSelectedClients.length > 0" class="add-auth-tags">
  103. <el-tag v-for="(client, idx) in addSelectedClients" :key="idx" closable size="large"
  104. @close="removeAddClient(idx)" class="add-auth-tag">
  105. <span class="add-auth-tag-index">{{ idx + 1 }}</span>
  106. {{ client.name }}
  107. </el-tag>
  108. </div>
  109. <el-button class="add-auth-btn" @click="openAddSelectClient">
  110. <el-icon>
  111. <Plus />
  112. </el-icon>
  113. {{ addSelectedClients.length > 0 ? '继续添加' : '选择客户' }}
  114. </el-button>
  115. </div>
  116. </el-form-item>
  117. </el-form>
  118. </div>
  119. <template #footer>
  120. <div class="add-dialog-footer">
  121. <el-button size="large" icon="Close" @click="addDialog.visible = false">取 消</el-button>
  122. <el-button size="large" type="primary" icon="Check" :loading="addBtnLoading"
  123. @click="confirmAdd">确认新增</el-button>
  124. </div>
  125. </template>
  126. </el-dialog>
  127. <!-- 新增员工-选择授权客户子对话框 -->
  128. <SelectClientDialog v-model="selectClientVisible" v-model:selected-clients="addSelectedClients"
  129. :search-results="selectClientResults" :search-loading="selectClientLoading" :searched="selectClientSearched"
  130. @search="handleSelectClientSearch" @confirm="selectClientVisible = false" />
  131. <!-- 员工详情对话框 -->
  132. <el-dialog v-model="detailDialog.visible" title="员工详情" width="640px" append-to-body class="detail-employee-dialog">
  133. <template v-if="detailDialog.data">
  134. <div class="detail-top-card">
  135. <el-avatar :size="64" :src="detailDialog.data.avatarUrl" icon="UserFilled" class="detail-avatar" />
  136. <div class="detail-top-info">
  137. <span class="detail-top-name">{{ detailDialog.data.name }}</span>
  138. <div class="detail-top-meta">
  139. <span class="detail-top-phone">{{ detailDialog.data.phone || '-' }}</span>
  140. <el-tag :type="detailDialog.data.status === '0' ? 'danger' : 'success'" size="small" round>
  141. {{ detailDialog.data.status === '0' ? '已禁用' : '已启用' }}
  142. </el-tag>
  143. </div>
  144. </div>
  145. </div>
  146. <div class="detail-grid">
  147. <div class="detail-cell">
  148. <span class="detail-cell-label">员工ID</span>
  149. <span class="detail-cell-value">{{ detailDialog.data.id }}</span>
  150. </div>
  151. <div class="detail-cell">
  152. <span class="detail-cell-label">注册时间</span>
  153. <span class="detail-cell-value">{{ detailDialog.data.createTime }}</span>
  154. </div>
  155. </div>
  156. <div class="detail-section-title">授权客户</div>
  157. <div v-if="detailDialog.data.authClientList && detailDialog.data.authClientList.length > 0"
  158. class="detail-client-list">
  159. <div class="detail-client-item" v-for="(client, idx) in detailDialog.data.authClientList" :key="idx">
  160. <div class="detail-client-avatar">{{ client.name?.charAt(0) }}</div>
  161. <div class="detail-client-body">
  162. <span class="detail-client-name">{{ client.name }}</span>
  163. <span class="detail-client-meta">{{ client.clientClass || '-' }} · {{ client.enterDate || '-' }}</span>
  164. </div>
  165. </div>
  166. </div>
  167. <el-empty v-else description="暂无授权客户" :image-size="48" />
  168. </template>
  169. </el-dialog>
  170. </div>
  171. </template>
  172. <script setup name="Customer" lang="ts">
  173. import { listEmployee, addEmployee, getEmployeeDetail, authEmployee, changeEmployeeStatus, adminResetPassword } from '@/api/system/employee';
  174. import { EmployeeVO, EmployeeQuery, EmployeeForm, ErpClientBriefVO } from '@/api/system/employee/types';
  175. import { searchErpClient, getErpClientByIds } from '@/api/erp/client';
  176. import { ErpClientVO } from '@/api/erp/client/types';
  177. import { checkPermi } from '@/utils/permission';
  178. import { globalHeaders } from '@/utils/request';
  179. import SelectClientDialog from './components/SelectClientDialog.vue';
  180. /** @Author: Antigravity */
  181. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  182. const employeeList = ref<EmployeeVO[]>([]);
  183. const buttonLoading = ref(false);
  184. const loading = ref(true);
  185. const showSearch = ref(true);
  186. const ids = ref<Array<string | number>>([]);
  187. const total = ref(0);
  188. const queryFormRef = ref<ElFormInstance>();
  189. // 授权客户对话框相关
  190. const authDialog = reactive({
  191. visible: false,
  192. employeeId: undefined as string | number | undefined
  193. });
  194. const authSearchLoading = ref(false);
  195. const authSearchResults = ref<ErpClientVO[]>([]);
  196. const authSearched = ref(false);
  197. const selectedAuthClients = ref<ErpClientVO[]>([]);
  198. // 详情对话框
  199. const detailDialog = reactive({
  200. visible: false,
  201. data: null as EmployeeVO | null
  202. });
  203. // 新增员工对话框
  204. const addDialog = reactive({
  205. visible: false
  206. });
  207. const addFormRef = ref<ElFormInstance>();
  208. const addForm = reactive({
  209. name: '',
  210. phone: '',
  211. avatar: undefined as string | undefined,
  212. avatarUrl: ''
  213. });
  214. const addFormRules = reactive({
  215. name: [{ required: true, message: '姓名不能为空', trigger: 'blur' }],
  216. phone: [{ required: true, message: '手机号不能为空', trigger: 'blur' }]
  217. });
  218. const addBtnLoading = ref(false);
  219. const addSelectedClients = ref<ErpClientVO[]>([]);
  220. // 新增员工-选择授权客户子对话框
  221. const selectClientVisible = ref(false);
  222. const selectClientLoading = ref(false);
  223. const selectClientResults = ref<ErpClientVO[]>([]);
  224. const selectClientSearched = ref(false);
  225. const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload';
  226. const uploadHeaders = ref(globalHeaders());
  227. watch(() => authDialog.visible, (val) => {
  228. if (val) initAuthDialog();
  229. });
  230. const data = reactive<PageData<EmployeeForm, EmployeeQuery>>({
  231. form: {} as any,
  232. queryParams: {
  233. pageNum: 1,
  234. pageSize: 10,
  235. name: undefined,
  236. phone: undefined,
  237. wechatOpenid: undefined
  238. },
  239. rules: {}
  240. });
  241. const { queryParams } = toRefs(data);
  242. /**
  243. * 处理列表数据,附加状态展示字段
  244. */
  245. const processList = (list: EmployeeVO[]) => {
  246. list.forEach(item => {
  247. (item as any)._statusActive = item.status !== '0';
  248. (item as any)._statusLoading = false;
  249. });
  250. };
  251. /** 查询员工列表 */
  252. const getList = async () => {
  253. loading.value = true;
  254. const res = await listEmployee(queryParams.value);
  255. const rows = res.rows as EmployeeVO[] || [];
  256. processList(rows);
  257. employeeList.value = rows;
  258. total.value = res.total;
  259. loading.value = false;
  260. };
  261. /** 搜索按钮操作 */
  262. const handleQuery = () => {
  263. queryParams.value.pageNum = 1;
  264. getList();
  265. };
  266. /** 重置按钮操作 */
  267. const resetQuery = () => {
  268. queryFormRef.value?.resetFields();
  269. handleQuery();
  270. };
  271. /** 多选框选中数据 */
  272. const handleSelectionChange = (selection: EmployeeVO[]) => {
  273. ids.value = selection.map((item) => item.id);
  274. };
  275. /** 状态切换 */
  276. const handleStatusChange = async (row: EmployeeVO & { _statusActive: boolean; _statusLoading: boolean }) => {
  277. const newStatus = row._statusActive ? '1' : '0';
  278. try {
  279. row._statusLoading = true;
  280. await changeEmployeeStatus({ id: row.id, status: newStatus });
  281. proxy?.$modal.msgSuccess(newStatus === '1' ? '已启用' : '已禁用');
  282. } catch (e: any) {
  283. // 失败回滚状态
  284. row._statusActive = !row._statusActive;
  285. proxy?.$modal.msgError(e?.msg || '操作失败');
  286. } finally {
  287. row._statusLoading = false;
  288. }
  289. };
  290. /** 授权员工按钮操作 */
  291. const handleAuth = (row: EmployeeVO) => {
  292. authDialog.employeeId = row.id;
  293. authDialog.visible = true;
  294. };
  295. /** 弹窗打开时初始化 */
  296. const initAuthDialog = async () => {
  297. authSearchResults.value = [];
  298. authSearched.value = false;
  299. selectedAuthClients.value = [];
  300. if (!authDialog.employeeId) return;
  301. try {
  302. const res = await getEmployeeDetail(authDialog.employeeId);
  303. const ids = res.data?.authClientFRowIDs;
  304. if (ids) {
  305. selectedAuthClients.value = await fetchClientsByIds(ids);
  306. }
  307. } catch (e) {
  308. console.error('加载已授权客户失败', e);
  309. }
  310. };
  311. /** 搜索ERP客户 */
  312. const handleAuthSearch = async (keyword: string) => {
  313. if (!keyword) {
  314. proxy?.$modal.msgWarning('请输入客户名称');
  315. return;
  316. }
  317. authSearchLoading.value = true;
  318. authSearched.value = true;
  319. try {
  320. const res = await searchErpClient(keyword);
  321. authSearchResults.value = res.data || [];
  322. } catch (e) {
  323. authSearchResults.value = [];
  324. } finally {
  325. authSearchLoading.value = false;
  326. }
  327. };
  328. /** 确认授权 */
  329. const confirmAuth = async () => {
  330. if (!selectedAuthClients.value || selectedAuthClients.value.length === 0) {
  331. proxy?.$modal.msgError('请至少选择一个ERP客户');
  332. return;
  333. }
  334. buttonLoading.value = true;
  335. try {
  336. const authClientFRowIDs = selectedAuthClients.value.map(c => c.rowId).join(',');
  337. await authEmployee({ id: authDialog.employeeId!, authClientFRowIDs });
  338. proxy?.$modal.msgSuccess('授权成功');
  339. authDialog.visible = false;
  340. getList();
  341. } finally {
  342. buttonLoading.value = false;
  343. }
  344. };
  345. /** 查看员工详情 */
  346. const handleDetail = async (row: EmployeeVO) => {
  347. detailDialog.visible = true;
  348. detailDialog.data = null;
  349. try {
  350. const res = await getEmployeeDetail(row.id);
  351. const data = res.data;
  352. if (data && data.authClientFRowIDs) {
  353. data.authClientList = await fetchClientsByIds(data.authClientFRowIDs) as any;
  354. }
  355. detailDialog.data = data;
  356. } catch (e) {
  357. proxy?.$modal.msgError('获取员工详情失败');
  358. detailDialog.visible = false;
  359. }
  360. };
  361. /** 重置员工密码 */
  362. const handleResetPwd = (row: EmployeeVO) => {
  363. proxy?.$modal.confirm(`确认将员工「${row.name}」的密码重置为 123456?`).then(async () => {
  364. try {
  365. await adminResetPassword({ id: row.id });
  366. proxy?.$modal.msgSuccess('密码已重置为 123456');
  367. } catch (e: any) {
  368. proxy?.$modal.msgError(e?.msg || '重置密码失败');
  369. }
  370. }).catch(() => { });
  371. };
  372. /** 新增按钮操作 */
  373. const handleAdd = () => {
  374. resetAddForm();
  375. addDialog.visible = true;
  376. };
  377. /** 重置新增表单 */
  378. const resetAddForm = () => {
  379. addForm.name = '';
  380. addForm.phone = '';
  381. addForm.avatar = undefined;
  382. addForm.avatarUrl = '';
  383. addSelectedClients.value = [];
  384. addFormRef.value?.resetFields();
  385. };
  386. /** 打开选择授权客户子对话框 */
  387. const openAddSelectClient = () => {
  388. selectClientVisible.value = true;
  389. };
  390. /** 搜索客户 */
  391. const handleSelectClientSearch = async (keyword: string) => {
  392. if (!keyword) {
  393. proxy?.$modal.msgWarning('请输入客户名称');
  394. return;
  395. }
  396. selectClientLoading.value = true;
  397. selectClientSearched.value = true;
  398. try {
  399. const res = await searchErpClient(keyword);
  400. selectClientResults.value = res.data || [];
  401. } catch (e) {
  402. selectClientResults.value = [];
  403. } finally {
  404. selectClientLoading.value = false;
  405. }
  406. };
  407. /** 头像上传成功回调 */
  408. const handleAddAvatarSuccess = (res: any) => {
  409. if (res.code === 200) {
  410. addForm.avatar = res.data.ossId;
  411. addForm.avatarUrl = res.data.url;
  412. } else {
  413. proxy?.$modal.msgError(res.msg || '上传失败');
  414. }
  415. };
  416. /** 头像上传前校验 */
  417. const beforeAddAvatarUpload = (file: any) => {
  418. const isImg = file.type.indexOf('image/') > -1;
  419. if (!isImg) {
  420. proxy?.$modal.msgError('请上传图片格式文件');
  421. return false;
  422. }
  423. const isLt5M = file.size / 1024 / 1024 < 5;
  424. if (!isLt5M) {
  425. proxy?.$modal.msgError('上传头像图片大小不能超过 5MB!');
  426. return false;
  427. }
  428. return true;
  429. };
  430. /** 确认新增员工 */
  431. const confirmAdd = async () => {
  432. const valid = await addFormRef.value?.validate().catch(() => false);
  433. if (!valid) return;
  434. addBtnLoading.value = true;
  435. try {
  436. const data: EmployeeForm = {
  437. name: addForm.name,
  438. phone: addForm.phone,
  439. password: '123456',
  440. authClientFRowIDs: addSelectedClients.value.map(c => c.rowId).join(',')
  441. };
  442. if (addForm.avatar) {
  443. data.avatar = addForm.avatar;
  444. }
  445. await addEmployee(data);
  446. proxy?.$modal.msgSuccess('新增成功');
  447. addDialog.visible = false;
  448. getList();
  449. } finally {
  450. addBtnLoading.value = false;
  451. }
  452. };
  453. /**
  454. * 根据 authClientFRowIDs 逗号分隔字符串,调用 ERP 批量接口反查客户列表
  455. */
  456. const fetchClientsByIds = async (authClientFRowIDs: string): Promise<ErpClientVO[]> => {
  457. const ids = authClientFRowIDs.split(',').map(s => s.trim()).filter(Boolean);
  458. if (ids.length === 0) return [];
  459. try {
  460. const res = await getErpClientByIds(ids.join(','));
  461. return (res.data || []).map(c => ({
  462. ...c,
  463. num: (c as any).num || c.rowId
  464. }));
  465. } catch (e) {
  466. console.error('批量查询ERP客户失败', e);
  467. return [];
  468. }
  469. };
  470. onMounted(() => {
  471. getList();
  472. });
  473. </script>
  474. <style scoped lang="scss">
  475. @use '@/assets/styles/page-common.scss' as *;
  476. /* ========== 详情弹窗 ========== */
  477. .detail-employee-dialog :deep(.el-dialog__header) {
  478. padding: 24px 28px 0;
  479. margin: 0;
  480. border-bottom: none;
  481. }
  482. .detail-employee-dialog :deep(.el-dialog__title) {
  483. font-size: 18px;
  484. font-weight: 600;
  485. color: #1f2937;
  486. letter-spacing: 0.5px;
  487. }
  488. .detail-employee-dialog :deep(.el-dialog__body) {
  489. padding: 20px 28px 28px;
  490. }
  491. .detail-top-card {
  492. display: flex;
  493. align-items: center;
  494. gap: 20px;
  495. padding: 24px;
  496. background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
  497. border-radius: 14px;
  498. margin-bottom: 24px;
  499. }
  500. .detail-avatar {
  501. flex-shrink: 0;
  502. border: 3px solid #fff;
  503. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  504. }
  505. .detail-top-info {
  506. display: flex;
  507. flex-direction: column;
  508. gap: 8px;
  509. }
  510. .detail-top-name {
  511. font-size: 22px;
  512. font-weight: 700;
  513. color: #1f2937;
  514. letter-spacing: 0.5px;
  515. }
  516. .detail-top-meta {
  517. display: flex;
  518. align-items: center;
  519. gap: 12px;
  520. }
  521. .detail-top-phone {
  522. font-size: 13px;
  523. color: #6b7280;
  524. }
  525. .detail-grid {
  526. display: grid;
  527. grid-template-columns: repeat(2, 1fr);
  528. gap: 16px;
  529. margin-bottom: 28px;
  530. }
  531. .detail-cell {
  532. display: flex;
  533. flex-direction: column;
  534. gap: 6px;
  535. padding: 14px 16px;
  536. background: #f9fafb;
  537. border-radius: 10px;
  538. border: 1px solid #f0f2f5;
  539. }
  540. .detail-cell-label {
  541. font-size: 12px;
  542. font-weight: 500;
  543. color: #9ca3af;
  544. text-transform: uppercase;
  545. letter-spacing: 0.5px;
  546. }
  547. .detail-cell-value {
  548. font-size: 14px;
  549. font-weight: 600;
  550. color: #1f2937;
  551. }
  552. .detail-section-title {
  553. font-size: 15px;
  554. font-weight: 600;
  555. color: #1f2937;
  556. margin-bottom: 16px;
  557. display: flex;
  558. align-items: center;
  559. gap: 8px;
  560. }
  561. .detail-section-title::before {
  562. content: '';
  563. width: 4px;
  564. height: 16px;
  565. background: #3b82f6;
  566. border-radius: 2px;
  567. }
  568. .detail-client-list {
  569. display: flex;
  570. flex-direction: column;
  571. gap: 10px;
  572. }
  573. .detail-client-item {
  574. display: flex;
  575. align-items: center;
  576. gap: 14px;
  577. padding: 14px 16px;
  578. background: #fff;
  579. border-radius: 10px;
  580. border: 1px solid #f0f2f5;
  581. transition: box-shadow 0.2s, border-color 0.2s;
  582. }
  583. .detail-client-item:hover {
  584. border-color: #e0e5ea;
  585. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  586. }
  587. .detail-client-avatar {
  588. width: 40px;
  589. height: 40px;
  590. border-radius: 10px;
  591. background: linear-gradient(135deg, #3b82f6, #2563eb);
  592. color: #fff;
  593. display: flex;
  594. align-items: center;
  595. justify-content: center;
  596. font-size: 16px;
  597. font-weight: 600;
  598. flex-shrink: 0;
  599. }
  600. .detail-client-body {
  601. display: flex;
  602. flex-direction: column;
  603. gap: 4px;
  604. }
  605. .detail-client-name {
  606. font-size: 14px;
  607. font-weight: 600;
  608. color: #1f2937;
  609. }
  610. .detail-client-meta {
  611. font-size: 12px;
  612. color: #9ca3af;
  613. }
  614. /* ========== 新增员工对话框 ========== */
  615. .add-employee-dialog :deep(.el-dialog__header) {
  616. padding: 24px 28px 0;
  617. margin: 0;
  618. }
  619. .add-employee-dialog :deep(.el-dialog__title) {
  620. font-size: 18px;
  621. font-weight: 600;
  622. color: #1a1a1a;
  623. letter-spacing: 0.3px;
  624. }
  625. .add-employee-dialog :deep(.el-dialog__body) {
  626. padding: 12px 28px 20px;
  627. }
  628. .add-form-wrapper {
  629. display: flex;
  630. flex-direction: column;
  631. align-items: center;
  632. }
  633. .add-avatar-area {
  634. display: flex;
  635. flex-direction: column;
  636. align-items: center;
  637. margin-bottom: 28px;
  638. }
  639. .add-avatar-uploader {
  640. cursor: pointer;
  641. }
  642. .add-avatar-mask {
  643. position: relative;
  644. width: 88px;
  645. height: 88px;
  646. border-radius: 50%;
  647. overflow: hidden;
  648. background: #f5f7fa;
  649. border: 2px solid #e8ecf1;
  650. display: flex;
  651. align-items: center;
  652. justify-content: center;
  653. transition: border-color 0.3s, box-shadow 0.3s;
  654. }
  655. .add-avatar-mask:hover {
  656. border-color: #409eff;
  657. box-shadow: 0 0 0 4px rgba(64, 158, 255, 0.1);
  658. }
  659. .add-avatar-mask:hover .add-avatar-overlay {
  660. opacity: 1;
  661. }
  662. .add-avatar-img {
  663. width: 100%;
  664. height: 100%;
  665. object-fit: cover;
  666. }
  667. .add-avatar-placeholder {
  668. font-size: 36px;
  669. color: #c0c4cc;
  670. }
  671. .add-avatar-overlay {
  672. position: absolute;
  673. inset: 0;
  674. background: rgba(0, 0, 0, 0.45);
  675. display: flex;
  676. flex-direction: column;
  677. align-items: center;
  678. justify-content: center;
  679. gap: 2px;
  680. opacity: 0;
  681. transition: opacity 0.3s;
  682. color: #fff;
  683. font-size: 11px;
  684. }
  685. .add-avatar-overlay .el-icon {
  686. font-size: 18px;
  687. }
  688. .add-avatar-tip {
  689. margin: 10px 0 0;
  690. font-size: 12px;
  691. color: #b0b5be;
  692. letter-spacing: 0.2px;
  693. }
  694. .add-form {
  695. width: 100%;
  696. }
  697. .add-form :deep(.el-form-item__label) {
  698. font-size: 13px;
  699. font-weight: 500;
  700. color: #4a4f5a;
  701. padding-bottom: 6px;
  702. }
  703. .add-form :deep(.el-input--large .el-input__wrapper) {
  704. border-radius: 8px;
  705. box-shadow: 0 0 0 1px #e4e7ed inset;
  706. transition: box-shadow 0.25s;
  707. }
  708. .add-form :deep(.el-input--large .el-input__wrapper:hover) {
  709. box-shadow: 0 0 0 1px #c6cacf inset;
  710. }
  711. .add-form :deep(.el-input--large.is-focus .el-input__wrapper) {
  712. box-shadow: 0 0 0 1px #409eff inset, 0 0 0 3px rgba(64, 158, 255, 0.08);
  713. }
  714. .add-auth-wrap {
  715. display: flex;
  716. flex-wrap: wrap;
  717. align-items: center;
  718. gap: 8px;
  719. }
  720. .add-auth-tags {
  721. display: flex;
  722. flex-wrap: wrap;
  723. gap: 6px;
  724. }
  725. .add-auth-tag {
  726. font-size: 13px;
  727. border-radius: 6px;
  728. padding: 6px 12px;
  729. border: none;
  730. background: #f0f5ff;
  731. color: #3370ff;
  732. }
  733. .add-auth-tag :deep(.el-tag__close) {
  734. color: #8fa8e0;
  735. }
  736. .add-auth-tag :deep(.el-tag__close:hover) {
  737. background: rgba(51, 112, 255, 0.12);
  738. color: #3370ff;
  739. }
  740. .add-auth-tag-index {
  741. display: inline-flex;
  742. align-items: center;
  743. justify-content: center;
  744. width: 18px;
  745. height: 18px;
  746. border-radius: 50%;
  747. background: #3370ff;
  748. color: #fff;
  749. font-size: 11px;
  750. font-weight: 600;
  751. margin-right: 6px;
  752. }
  753. .add-auth-btn {
  754. flex-shrink: 0;
  755. border-radius: 6px;
  756. border: 1px dashed #c0c4cc;
  757. background: #fff;
  758. color: #606266;
  759. font-size: 13px;
  760. padding: 8px 16px;
  761. transition: border-color 0.25s, color 0.25s, background 0.25s;
  762. }
  763. .add-auth-btn:hover {
  764. border-color: #409eff;
  765. color: #409eff;
  766. background: #f0f5ff;
  767. }
  768. .add-dialog-footer {
  769. display: flex;
  770. justify-content: flex-end;
  771. gap: 12px;
  772. padding-top: 4px;
  773. }
  774. .add-dialog-footer .el-button {
  775. border-radius: 8px;
  776. padding: 10px 28px;
  777. font-size: 14px;
  778. font-weight: 500;
  779. letter-spacing: 0.5px;
  780. }
  781. .add-dialog-footer .el-button--primary {
  782. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.25);
  783. }
  784. .add-dialog-footer .el-button--primary:hover {
  785. box-shadow: 0 4px 16px rgba(64, 158, 255, 0.35);
  786. }
  787. /* ==================== 页面级样式优化 ==================== */
  788. .employee-page {
  789. @include page-common-list;
  790. }
  791. </style>