RuleMethodPanel.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. <template>
  2. <div class="tab-panel">
  3. <el-form ref="form" :model="form" :rules="formRules" label-position="top" class="rule-form">
  4. <section class="form-section">
  5. <header>
  6. <h3><span class="required">*</span>规则名称</h3>
  7. </header>
  8. <el-form-item prop="name">
  9. <el-input v-model.trim="form.name" maxlength="64" placeholder="请输入打卡规则名称" :disabled="readonly" />
  10. </el-form-item>
  11. </section>
  12. <section class="form-section">
  13. <header>
  14. <h3><span class="required">*</span>适用范围</h3>
  15. <p>先选择范围类型,再多选具体对象;提交生效时会检测时间范围冲突</p>
  16. </header>
  17. <el-form-item label="范围类型">
  18. <el-select v-model="uiScopeType" clearable filterable placeholder="请选择范围类型" :disabled="readonly"
  19. class="scope-type-select" @change="onScopeTypeChange">
  20. <el-option v-for="item in scopeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
  21. </el-select>
  22. </el-form-item>
  23. <el-form-item v-if="uiScopeType" :label="scopeTargetLabel">
  24. <el-select v-model="selectedScopeIds" multiple clearable filterable :placeholder="scopeTargetPlaceholder"
  25. :disabled="readonly" :loading="scopeOptionsLoading" class="scope-target-select" @change="onScopeIdsChange">
  26. <el-option v-for="item in scopeOptions" :key="String(item.id)" :label="item.name" :value="item.id" />
  27. </el-select>
  28. </el-form-item>
  29. </section>
  30. <section class="form-section">
  31. <header>
  32. <h3><span class="required">*</span>打卡方式</h3>
  33. <p>手机和考勤机满足任意一项即可打卡</p>
  34. </header>
  35. <el-form-item label="选择方式" prop="method">
  36. <el-radio-group v-model="form.method" class="method-radio" :disabled="readonly">
  37. <el-radio label="phone">手机</el-radio>
  38. <el-radio label="device">考勤机</el-radio>
  39. <el-radio label="phone-device">手机+考勤机</el-radio>
  40. </el-radio-group>
  41. </el-form-item>
  42. </section>
  43. <section class="form-section">
  44. <header>
  45. <h3>打卡位置</h3>
  46. <p>设置打卡地点范围(GPS / 客户现场)</p>
  47. </header>
  48. <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-location')">添加</el-button>
  49. <el-table :data="form.locations || []" class="location-table" size="small" empty-text="暂未添加打卡位置">
  50. <el-table-column type="index" label="序号" width="60" align="center" />
  51. <el-table-column prop="name" label="地点名称" minWidth="220" />
  52. <el-table-column prop="range" label="有效范围" width="160" />
  53. <el-table-column v-if="!readonly" label="操作" width="80" align="left">
  54. <template slot-scope="{ $index }">
  55. <el-button type="text" size="mini" class="is-danger"
  56. @click="form.locations.splice($index, 1)">删除</el-button>
  57. </template>
  58. </el-table-column>
  59. </el-table>
  60. <el-form-item label="范围外打卡" class="range-form-item">
  61. <el-select v-model="form.outOfRange" placeholder="选择范围外打卡策略" style="width:240px" :disabled="readonly"
  62. @change="onOutOfRangeChange">
  63. <el-option label="记录为地点异常" value="异常" />
  64. <el-option label="允许打卡" value="允许" />
  65. <el-option label="不允许打卡" value="不允许" />
  66. </el-select>
  67. </el-form-item>
  68. </section>
  69. <section class="form-section">
  70. <header>
  71. <h3>打卡Wi-Fi</h3>
  72. <p>绑定办公网络 Wi-Fi</p>
  73. </header>
  74. <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-wifi')">添加</el-button>
  75. <el-table :data="form.wifis || []" class="location-table" size="small" empty-text="暂未添加打卡Wi-Fi">
  76. <el-table-column type="index" label="序号" width="60" align="center" />
  77. <el-table-column prop="ssid" label="WiFi 名称" minWidth="200" />
  78. <el-table-column prop="bssid" label="MAC 地址" minWidth="200" />
  79. <el-table-column v-if="!readonly" label="操作" width="80" align="left">
  80. <template slot-scope="{ $index }">
  81. <el-button type="text" size="mini" class="is-danger" @click="form.wifis.splice($index, 1)">删除</el-button>
  82. </template>
  83. </el-table-column>
  84. </el-table>
  85. <div class="wifi-tip">
  86. <h4>获取方式</h4>
  87. <ol>
  88. <li>此页面会自动采集当前连接 Wi-Fi 的 BSSID</li>
  89. <li>若公司有多个 Wi-Fi,请依次手动连接到各个 Wi-Fi,再切换到此页面来采集数据</li>
  90. <li>采集完成后,选择所需 Wi-Fi 并提交</li>
  91. <li>请确保 Wi-Fi 路由器 BSSID 不会动态变化,否则可能导致连上 Wi-Fi 但提示"不在打卡范围内"等异常</li>
  92. </ol>
  93. </div>
  94. </section>
  95. <section v-if="form.method !== 'phone'" class="form-section">
  96. <header>
  97. <h3>考勤设备</h3>
  98. <p>添加并管理用于打卡的考勤机设备</p>
  99. </header>
  100. <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-device')">添加考勤机</el-button>
  101. <el-table :data="form.devices || []" class="location-table device-table" size="small"
  102. empty-text="暂未添加考勤设备,点击右上角“添加考勤机”按钮添加">
  103. <el-table-column type="index" label="序号" width="60" align="center" />
  104. <el-table-column label="设备名称" minWidth="120">
  105. <template slot-scope="{ row }">
  106. <!-- 点设备名称开详情,替代原操作列「查看详情」入口 -->
  107. <el-link v-if="row.name" type="primary" :underline="false" @click="$emit('device-view', row)">{{ row.name }}</el-link>
  108. <span v-else>{{ row.name }}</span>
  109. </template>
  110. </el-table-column>
  111. <el-table-column prop="sn" label="SN码" minWidth="140" />
  112. <el-table-column prop="ip" label="IP地址" minWidth="120" />
  113. <el-table-column prop="deviceType" label="设备类型" minWidth="110" />
  114. <el-table-column label="在线状态" width="100" align="center">
  115. <template slot-scope="{ row }">
  116. <span class="status-dot" :class="row.online ? 'on' : 'off'">
  117. <i></i>{{ row.online ? '在线' : '离线' }}
  118. </span>
  119. </template>
  120. </el-table-column>
  121. <el-table-column prop="onlineDuration" label="在线时长" width="110" align="center" />
  122. <el-table-column prop="department" label="绑定部门" minWidth="140" />
  123. <el-table-column prop="lastSync" label="最后同步时间" width="160" />
  124. <el-table-column label="操作" minWidth="280" align="left">
  125. <template slot-scope="{ row }">
  126. <template v-if="!readonly">
  127. <el-button type="text" size="mini" @click="$emit('device-edit', row)">编辑</el-button>
  128. <el-button type="text" size="mini" @click="$emit('device-test', row)">连接测试</el-button>
  129. <el-button type="text" size="mini" @click="$emit('device-sync', row)">同步数据</el-button>
  130. <el-button type="text" size="mini" @click="$emit('device-restart', row)">重启设备</el-button>
  131. <el-button type="text" size="mini" class="is-danger" @click="$emit('device-delete', row)">删除</el-button>
  132. <el-button type="text" size="mini" @click="$emit('device-log', row)">日志</el-button>
  133. </template>
  134. </template>
  135. </el-table-column>
  136. </el-table>
  137. </section>
  138. </el-form>
  139. </div>
  140. </template>
  141. <script>
  142. import {
  143. ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS,
  144. attendanceRuleScopeTypeLabel,
  145. defaultAttendanceRuleScopePriority,
  146. } from '@/api/hr/attendance';
  147. import { getPositionPage } from '@/api/hr';
  148. import { listOrganizations, fetchAllUsers, getFactoryAreaPage } from '@/api/organization';
  149. /* 机构范围选项:取登录缓存机构列表(loginChangeGroupVOList,即用户所属机构) */
  150. import { getOrgOptions } from '@/utils/current-org';
  151. const ORG_TYPE_BY_SCOPE = {
  152. DEPT: ['30'],
  153. FACTORY: ['40'],
  154. LEGAL_ENTITY: ['10', '20'],
  155. };
  156. function normalizeOptionId(id) {
  157. if (id == null || id === '') return '';
  158. return String(id);
  159. }
  160. export default {
  161. name: 'RuleMethodPanel',
  162. props: {
  163. form: { type: Object, required: true },
  164. rules: { type: Object, default: () => ({}) },
  165. readonly: { type: Boolean, default: false },
  166. orgOptions: { type: Array, default: () => [] },
  167. },
  168. data() {
  169. return {
  170. scopeTypeOptions: ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS,
  171. uiScopeType: '',
  172. selectedScopeIds: [],
  173. scopeOptions: [],
  174. scopeOptionsLoading: false,
  175. syncingFromForm: false,
  176. optionCache: {},
  177. };
  178. },
  179. computed: {
  180. formRules() {
  181. return {
  182. name: [{ required: true, message: '请输入打卡规则名称', trigger: 'blur' }],
  183. method: [{ required: true, message: '请选择打卡方式', trigger: 'change' }],
  184. ...this.rules,
  185. };
  186. },
  187. scopeTypeLabel() {
  188. return attendanceRuleScopeTypeLabel(this.uiScopeType) || '';
  189. },
  190. scopeTargetLabel() {
  191. return this.scopeTypeLabel ? `选择${this.scopeTypeLabel}` : '选择范围';
  192. },
  193. scopeTargetPlaceholder() {
  194. return this.scopeTypeLabel ? `请选择${this.scopeTypeLabel}` : '请选择';
  195. },
  196. },
  197. watch: {
  198. 'form.scopes': {
  199. deep: true,
  200. handler() {
  201. this.syncFromForm();
  202. },
  203. },
  204. 'form.id'() {
  205. this.syncFromForm(true);
  206. },
  207. },
  208. created() {
  209. if (!Array.isArray(this.form.scopes)) this.$set(this.form, 'scopes', []);
  210. this.syncFromForm(true);
  211. },
  212. methods: {
  213. onOutOfRangeChange(value) {
  214. (this.form.locations || []).forEach((item, index) => {
  215. this.$set(this.form.locations[index], 'outsidePolicy', value);
  216. });
  217. },
  218. syncFromForm(forceLoad = false) {
  219. const scopes = Array.isArray(this.form.scopes) ? this.form.scopes : [];
  220. const type =
  221. scopes.find((item) => item?.scopeType)?.scopeType ||
  222. this.form.scopeType ||
  223. '';
  224. const ids = scopes
  225. .filter((item) => item && item.scopeType === type && item.scopeId != null && item.scopeId !== '')
  226. .map((item) => normalizeOptionId(item.scopeId));
  227. this.syncingFromForm = true;
  228. this.uiScopeType = type || '';
  229. this.selectedScopeIds = ids;
  230. this.$nextTick(() => {
  231. this.syncingFromForm = false;
  232. });
  233. if (this.uiScopeType) {
  234. this.loadScopeOptions(this.uiScopeType, forceLoad).then(() => {
  235. this.hydrateScopeNames();
  236. });
  237. } else {
  238. this.scopeOptions = [];
  239. }
  240. },
  241. async onScopeTypeChange(type) {
  242. if (this.syncingFromForm) return;
  243. this.selectedScopeIds = [];
  244. this.form.scopeType = type || '';
  245. this.form.scopeId = '';
  246. this.form.scope = '';
  247. this.$set(this.form, 'scopes', []);
  248. if (!type) {
  249. this.scopeOptions = [];
  250. return;
  251. }
  252. await this.loadScopeOptions(type, true);
  253. },
  254. onScopeIdsChange(ids) {
  255. if (this.syncingFromForm) return;
  256. const nextIds = Array.isArray(ids) ? ids.filter((id) => id != null && id !== '') : [];
  257. const type = this.uiScopeType;
  258. if (!type) {
  259. this.$set(this.form, 'scopes', []);
  260. return;
  261. }
  262. const optionMap = (this.scopeOptions || []).reduce((map, item) => {
  263. map[String(item.id)] = item;
  264. return map;
  265. }, {});
  266. const scopes = nextIds.map((id, index) => {
  267. const option = optionMap[String(id)];
  268. return {
  269. scopeType: type,
  270. scopeId: normalizeOptionId(id),
  271. priority: defaultAttendanceRuleScopePriority(type) || index + 1,
  272. scopeName: option?.name || '',
  273. };
  274. });
  275. this.$set(this.form, 'scopes', scopes);
  276. const first = scopes[0];
  277. this.form.scopeType = type;
  278. this.form.scopeId = first?.scopeId ?? '';
  279. this.form.scope = first?.scopeId ?? '';
  280. },
  281. hydrateScopeNames() {
  282. const optionMap = (this.scopeOptions || []).reduce((map, item) => {
  283. map[String(item.id)] = item;
  284. return map;
  285. }, {});
  286. (this.form.scopes || []).forEach((item) => {
  287. if (!item || item.scopeName) return;
  288. const option = optionMap[String(item.scopeId)];
  289. if (option?.name) item.scopeName = option.name;
  290. });
  291. },
  292. async loadScopeOptions(type, force = false) {
  293. if (!type) {
  294. this.scopeOptions = [];
  295. return;
  296. }
  297. if (!force && this.optionCache[type]) {
  298. this.scopeOptions = this.optionCache[type];
  299. this.alignSelectedIds();
  300. return;
  301. }
  302. this.scopeOptionsLoading = true;
  303. try {
  304. let options = [];
  305. if (type === 'EMPLOYEE') {
  306. options = await this.loadEmployeeOptions();
  307. } else if (type === 'POSITION') {
  308. options = await this.loadPositionOptions();
  309. } else {
  310. options = await this.loadOrganizationOptions(type);
  311. }
  312. this.optionCache[type] = options;
  313. this.scopeOptions = options;
  314. this.alignSelectedIds();
  315. } catch (error) {
  316. this.scopeOptions = [];
  317. } finally {
  318. this.scopeOptionsLoading = false;
  319. }
  320. },
  321. alignSelectedIds() {
  322. if (!this.selectedScopeIds.length || !this.scopeOptions.length) return;
  323. const optionIds = this.scopeOptions.map((item) => String(item.id));
  324. const next = this.selectedScopeIds
  325. .map((id) => {
  326. const hit = this.scopeOptions.find((item) => String(item.id) === String(id));
  327. return hit ? hit.id : id;
  328. })
  329. .filter((id) => optionIds.includes(String(id)));
  330. this.selectedScopeIds = next;
  331. },
  332. async loadEmployeeOptions() {
  333. const data = await fetchAllUsers({ isQueryLZ: 0 }, { pageSize: 200, maxPages: 30 });
  334. return (data.list || []).map((user) => ({
  335. id: normalizeOptionId(user.id),
  336. name: `${user.name || '未命名'}${user.jobNumber ? `(${user.jobNumber})` : ''}`,
  337. }));
  338. },
  339. async loadPositionOptions() {
  340. const data = await getPositionPage({ pageNum: 1, size: 1000, status: 1 });
  341. return (data?.list || []).map((item) => ({
  342. id: normalizeOptionId(item.id ?? item.positionId),
  343. name: item.positionName || item.name || '未命名岗位',
  344. }));
  345. },
  346. async loadOrganizationOptions(type) {
  347. // ORG(机构):只取登录缓存机构列表(用户所属机构,如「中赢合力」),
  348. // 与列表页「所属机构」筛选同一口径;之前误用全量组织树,把所有公司/部门都列了出来
  349. if (type === 'ORG') {
  350. const cached = getOrgOptions();
  351. if (cached.length) {
  352. return cached.map((item) => ({
  353. id: normalizeOptionId(item.id),
  354. name: item.name || '未命名组织',
  355. }));
  356. }
  357. // 登录缓存为空时兜底走全量组织树,避免下拉无选项
  358. }
  359. /*
  360. * DEPT(部门):只列「当前登录用户所属机构」下的部门(含各级子部门),
  361. * 不再把其它机构的部门列出来。
  362. * 需要 parentId 做层级下钻,而父组件传下来的 orgOptions 只有 { value, label, type },
  363. * 因此这里直接读扁平组织列表(listOrganizations 带 parentId,且有 1 分钟共享缓存)。
  364. */
  365. if (type === 'DEPT') {
  366. const { list: scoped, nameById } = await this.loadDepartmentsUnderCurrentOrg();
  367. if (scoped.length) {
  368. /* 编辑旧单据时,已选部门若不在「当前机构下」的列表里,补一条用于回显,
  369. 否则 el-select 找不到选项会退化成直接显示原始 id。 */
  370. const selected = (this.selectedScopeIds || []).map((id) => normalizeOptionId(id));
  371. const missing = selected.filter(
  372. (id) => id && !scoped.some((item) => String(item.id) === id),
  373. );
  374. return missing.length
  375. ? scoped.concat(
  376. missing.map((id) => ({ id, name: nameById[id] || id })),
  377. )
  378. : scoped;
  379. }
  380. // 拿不到层级关系(parentId 缺失)时,退回下面「全量 type=30」的兜底逻辑
  381. }
  382. let list = [];
  383. if (Array.isArray(this.orgOptions) && this.orgOptions.length) {
  384. list = this.orgOptions.map((item) => ({
  385. id: normalizeOptionId(item.value ?? item.id),
  386. name: item.label || item.name || '未命名组织',
  387. type: item.type,
  388. }));
  389. } else {
  390. const raw = await listOrganizations();
  391. list = (raw || []).map((item) => ({
  392. id: normalizeOptionId(item.id),
  393. name: item.name || '未命名组织',
  394. type: item.type,
  395. }));
  396. }
  397. const allowedTypes = ORG_TYPE_BY_SCOPE[type];
  398. if (allowedTypes) {
  399. const filtered = list.filter((item) => allowedTypes.includes(String(item.type)));
  400. // FACTORY:组织树里没有 type=40 的工厂节点时,兜底请求工厂区域接口
  401. // (/main/factoryarea/page?type=1,与员工档案「工厂」下拉同一数据源)
  402. if (type === 'FACTORY' && !filtered.length) {
  403. return this.loadFactoryAreaOptions();
  404. }
  405. return filtered;
  406. }
  407. // ORG:机构,展示全部组织单元
  408. return list;
  409. },
  410. /**
  411. * 「当前登录用户所属机构」下的所有部门(含各级子部门)
  412. * 机构取登录缓存 loginChangeGroupVOList(getOrgOptions),
  413. * 部门按扁平组织列表的 parentId 逐级下钻,只收集 type=30 的节点;
  414. * 机构自身、以及其它机构的部门都不返回。
  415. */
  416. async loadDepartmentsUnderCurrentOrg() {
  417. const raw = await listOrganizations();
  418. const rows = Array.isArray(raw) ? raw : raw?.list || raw?.records || raw?.rows || [];
  419. const nodes = rows
  420. .filter((item) => item && item.id != null && item.id !== '')
  421. .map((item) => ({
  422. id: normalizeOptionId(item.id),
  423. name: item.name || '未命名部门',
  424. parentId:
  425. item.parentId != null && item.parentId !== ''
  426. ? normalizeOptionId(item.parentId)
  427. : '',
  428. type: item.type,
  429. }));
  430. const orgIds = getOrgOptions().map((item) => normalizeOptionId(item.id));
  431. if (!orgIds.length || !nodes.length) return { list: [], nameById: {} };
  432. /* 全量部门名称索引:仅用于「已选值不在当前机构下」时的回显兜底 */
  433. const nameById = nodes.reduce((map, node) => {
  434. if (String(node.type) === '30') map[node.id] = node.name;
  435. return map;
  436. }, {});
  437. /* 只按 parentId 建索引:没有 parentId 的节点无法判断归属,直接不参与下钻 */
  438. const childrenOf = new Map();
  439. nodes.forEach((node) => {
  440. if (!node.parentId) return;
  441. if (!childrenOf.has(node.parentId)) childrenOf.set(node.parentId, []);
  442. childrenOf.get(node.parentId).push(node);
  443. });
  444. const result = [];
  445. const visited = new Set();
  446. const walk = (parentId) => {
  447. (childrenOf.get(parentId) || []).forEach((node) => {
  448. if (visited.has(node.id)) return;
  449. visited.add(node.id);
  450. /* 机构本身不作为部门选项;命中部门(type=30)后仍继续下钻子部门 */
  451. if (String(node.type) === '30') {
  452. result.push({ id: node.id, name: node.name });
  453. }
  454. walk(node.id);
  455. });
  456. };
  457. orgIds.forEach((id) => walk(id));
  458. return { list: result, nameById };
  459. },
  460. /** 工厂区域列表(type=1 工厂),组织树无工厂节点时的兜底数据源 */
  461. async loadFactoryAreaOptions() {
  462. const data = await getFactoryAreaPage({ pageNum: 1, size: 999, type: 1 });
  463. const rows = Array.isArray(data?.list) ? data.list : Array.isArray(data) ? data : [];
  464. return rows
  465. .map((item) => ({
  466. id: normalizeOptionId(item.id),
  467. name: item.name || item.factoryName || item.pathName || `工厂 ${item.id}`,
  468. }))
  469. .filter((item) => item.id != null && item.id !== '' && item.name);
  470. },
  471. validate(callback) {
  472. return this.$refs.form?.validate(callback);
  473. },
  474. validateField(prop) {
  475. return this.$refs.form?.validateField?.(prop);
  476. },
  477. },
  478. };
  479. </script>
  480. <style scoped>
  481. .scope-type-select,
  482. .scope-target-select {
  483. width: 100%;
  484. }
  485. .required {
  486. margin-right: 2px;
  487. color: #f56c6c;
  488. }
  489. </style>