| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- <template>
- <div class="tab-panel">
- <el-form ref="form" :model="form" :rules="formRules" label-position="top" class="rule-form">
- <section class="form-section">
- <header>
- <h3><span class="required">*</span>规则名称</h3>
- </header>
- <el-form-item prop="name">
- <el-input v-model.trim="form.name" maxlength="64" placeholder="请输入打卡规则名称" :disabled="readonly" />
- </el-form-item>
- </section>
- <section class="form-section">
- <header>
- <h3><span class="required">*</span>适用范围</h3>
- <p>先选择范围类型,再多选具体对象;提交生效时会检测时间范围冲突</p>
- </header>
- <el-form-item label="范围类型">
- <el-select v-model="uiScopeType" clearable filterable placeholder="请选择范围类型" :disabled="readonly"
- class="scope-type-select" @change="onScopeTypeChange">
- <el-option v-for="item in scopeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
- </el-select>
- </el-form-item>
- <el-form-item v-if="uiScopeType" :label="scopeTargetLabel">
- <el-select v-model="selectedScopeIds" multiple clearable filterable :placeholder="scopeTargetPlaceholder"
- :disabled="readonly" :loading="scopeOptionsLoading" class="scope-target-select" @change="onScopeIdsChange">
- <el-option v-for="item in scopeOptions" :key="String(item.id)" :label="item.name" :value="item.id" />
- </el-select>
- </el-form-item>
- </section>
- <section class="form-section">
- <header>
- <h3><span class="required">*</span>打卡方式</h3>
- <p>手机和考勤机满足任意一项即可打卡</p>
- </header>
- <el-form-item label="选择方式" prop="method">
- <el-radio-group v-model="form.method" class="method-radio" :disabled="readonly">
- <el-radio label="phone">手机</el-radio>
- <el-radio label="device">考勤机</el-radio>
- <el-radio label="phone-device">手机+考勤机</el-radio>
- </el-radio-group>
- </el-form-item>
- </section>
- <section class="form-section">
- <header>
- <h3>打卡位置</h3>
- <p>设置打卡地点范围(GPS / 客户现场)</p>
- </header>
- <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-location')">添加</el-button>
- <el-table :data="form.locations || []" class="location-table" size="small" empty-text="暂未添加打卡位置">
- <el-table-column type="index" label="序号" width="60" align="center" />
- <el-table-column prop="name" label="地点名称" minWidth="220" />
- <el-table-column prop="range" label="有效范围" width="160" />
- <el-table-column v-if="!readonly" label="操作" width="80" align="left">
- <template slot-scope="{ $index }">
- <el-button type="text" size="mini" class="is-danger"
- @click="form.locations.splice($index, 1)">删除</el-button>
- </template>
- </el-table-column>
- </el-table>
- <el-form-item label="范围外打卡" class="range-form-item">
- <el-select v-model="form.outOfRange" placeholder="选择范围外打卡策略" style="width:240px" :disabled="readonly"
- @change="onOutOfRangeChange">
- <el-option label="记录为地点异常" value="异常" />
- <el-option label="允许打卡" value="允许" />
- <el-option label="不允许打卡" value="不允许" />
- </el-select>
- </el-form-item>
- </section>
- <section class="form-section">
- <header>
- <h3>打卡Wi-Fi</h3>
- <p>绑定办公网络 Wi-Fi</p>
- </header>
- <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-wifi')">添加</el-button>
- <el-table :data="form.wifis || []" class="location-table" size="small" empty-text="暂未添加打卡Wi-Fi">
- <el-table-column type="index" label="序号" width="60" align="center" />
- <el-table-column prop="ssid" label="WiFi 名称" minWidth="200" />
- <el-table-column prop="bssid" label="MAC 地址" minWidth="200" />
- <el-table-column v-if="!readonly" label="操作" width="80" align="left">
- <template slot-scope="{ $index }">
- <el-button type="text" size="mini" class="is-danger" @click="form.wifis.splice($index, 1)">删除</el-button>
- </template>
- </el-table-column>
- </el-table>
- <div class="wifi-tip">
- <h4>获取方式</h4>
- <ol>
- <li>此页面会自动采集当前连接 Wi-Fi 的 BSSID</li>
- <li>若公司有多个 Wi-Fi,请依次手动连接到各个 Wi-Fi,再切换到此页面来采集数据</li>
- <li>采集完成后,选择所需 Wi-Fi 并提交</li>
- <li>请确保 Wi-Fi 路由器 BSSID 不会动态变化,否则可能导致连上 Wi-Fi 但提示"不在打卡范围内"等异常</li>
- </ol>
- </div>
- </section>
- <section v-if="form.method !== 'phone'" class="form-section">
- <header>
- <h3>考勤设备</h3>
- <p>添加并管理用于打卡的考勤机设备</p>
- </header>
- <el-button v-if="!readonly" size="small" icon="el-icon-plus" @click="$emit('add-device')">添加考勤机</el-button>
- <el-table :data="form.devices || []" class="location-table device-table" size="small"
- empty-text="暂未添加考勤设备,点击右上角“添加考勤机”按钮添加">
- <el-table-column type="index" label="序号" width="60" align="center" />
- <el-table-column label="设备名称" minWidth="120">
- <template slot-scope="{ row }">
- <!-- 点设备名称开详情,替代原操作列「查看详情」入口 -->
- <el-link v-if="row.name" type="primary" :underline="false" @click="$emit('device-view', row)">{{ row.name }}</el-link>
- <span v-else>{{ row.name }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="sn" label="SN码" minWidth="140" />
- <el-table-column prop="ip" label="IP地址" minWidth="120" />
- <el-table-column prop="deviceType" label="设备类型" minWidth="110" />
- <el-table-column label="在线状态" width="100" align="center">
- <template slot-scope="{ row }">
- <span class="status-dot" :class="row.online ? 'on' : 'off'">
- <i></i>{{ row.online ? '在线' : '离线' }}
- </span>
- </template>
- </el-table-column>
- <el-table-column prop="onlineDuration" label="在线时长" width="110" align="center" />
- <el-table-column prop="department" label="绑定部门" minWidth="140" />
- <el-table-column prop="lastSync" label="最后同步时间" width="160" />
- <el-table-column label="操作" minWidth="280" align="left">
- <template slot-scope="{ row }">
- <template v-if="!readonly">
- <el-button type="text" size="mini" @click="$emit('device-edit', row)">编辑</el-button>
- <el-button type="text" size="mini" @click="$emit('device-test', row)">连接测试</el-button>
- <el-button type="text" size="mini" @click="$emit('device-sync', row)">同步数据</el-button>
- <el-button type="text" size="mini" @click="$emit('device-restart', row)">重启设备</el-button>
- <el-button type="text" size="mini" class="is-danger" @click="$emit('device-delete', row)">删除</el-button>
- <el-button type="text" size="mini" @click="$emit('device-log', row)">日志</el-button>
- </template>
- </template>
- </el-table-column>
- </el-table>
- </section>
- </el-form>
- </div>
- </template>
- <script>
- import {
- ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS,
- attendanceRuleScopeTypeLabel,
- defaultAttendanceRuleScopePriority,
- } from '@/api/hr/attendance';
- import { getPositionPage } from '@/api/hr';
- import { listOrganizations, fetchAllUsers, getFactoryAreaPage } from '@/api/organization';
- /* 机构范围选项:取登录缓存机构列表(loginChangeGroupVOList,即用户所属机构) */
- import { getOrgOptions } from '@/utils/current-org';
- const ORG_TYPE_BY_SCOPE = {
- DEPT: ['30'],
- FACTORY: ['40'],
- LEGAL_ENTITY: ['10', '20'],
- };
- function normalizeOptionId(id) {
- if (id == null || id === '') return '';
- return String(id);
- }
- export default {
- name: 'RuleMethodPanel',
- props: {
- form: { type: Object, required: true },
- rules: { type: Object, default: () => ({}) },
- readonly: { type: Boolean, default: false },
- orgOptions: { type: Array, default: () => [] },
- },
- data() {
- return {
- scopeTypeOptions: ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS,
- uiScopeType: '',
- selectedScopeIds: [],
- scopeOptions: [],
- scopeOptionsLoading: false,
- syncingFromForm: false,
- optionCache: {},
- };
- },
- computed: {
- formRules() {
- return {
- name: [{ required: true, message: '请输入打卡规则名称', trigger: 'blur' }],
- method: [{ required: true, message: '请选择打卡方式', trigger: 'change' }],
- ...this.rules,
- };
- },
- scopeTypeLabel() {
- return attendanceRuleScopeTypeLabel(this.uiScopeType) || '';
- },
- scopeTargetLabel() {
- return this.scopeTypeLabel ? `选择${this.scopeTypeLabel}` : '选择范围';
- },
- scopeTargetPlaceholder() {
- return this.scopeTypeLabel ? `请选择${this.scopeTypeLabel}` : '请选择';
- },
- },
- watch: {
- 'form.scopes': {
- deep: true,
- handler() {
- this.syncFromForm();
- },
- },
- 'form.id'() {
- this.syncFromForm(true);
- },
- },
- created() {
- if (!Array.isArray(this.form.scopes)) this.$set(this.form, 'scopes', []);
- this.syncFromForm(true);
- },
- methods: {
- onOutOfRangeChange(value) {
- (this.form.locations || []).forEach((item, index) => {
- this.$set(this.form.locations[index], 'outsidePolicy', value);
- });
- },
- syncFromForm(forceLoad = false) {
- const scopes = Array.isArray(this.form.scopes) ? this.form.scopes : [];
- const type =
- scopes.find((item) => item?.scopeType)?.scopeType ||
- this.form.scopeType ||
- '';
- const ids = scopes
- .filter((item) => item && item.scopeType === type && item.scopeId != null && item.scopeId !== '')
- .map((item) => normalizeOptionId(item.scopeId));
- this.syncingFromForm = true;
- this.uiScopeType = type || '';
- this.selectedScopeIds = ids;
- this.$nextTick(() => {
- this.syncingFromForm = false;
- });
- if (this.uiScopeType) {
- this.loadScopeOptions(this.uiScopeType, forceLoad).then(() => {
- this.hydrateScopeNames();
- });
- } else {
- this.scopeOptions = [];
- }
- },
- async onScopeTypeChange(type) {
- if (this.syncingFromForm) return;
- this.selectedScopeIds = [];
- this.form.scopeType = type || '';
- this.form.scopeId = '';
- this.form.scope = '';
- this.$set(this.form, 'scopes', []);
- if (!type) {
- this.scopeOptions = [];
- return;
- }
- await this.loadScopeOptions(type, true);
- },
- onScopeIdsChange(ids) {
- if (this.syncingFromForm) return;
- const nextIds = Array.isArray(ids) ? ids.filter((id) => id != null && id !== '') : [];
- const type = this.uiScopeType;
- if (!type) {
- this.$set(this.form, 'scopes', []);
- return;
- }
- const optionMap = (this.scopeOptions || []).reduce((map, item) => {
- map[String(item.id)] = item;
- return map;
- }, {});
- const scopes = nextIds.map((id, index) => {
- const option = optionMap[String(id)];
- return {
- scopeType: type,
- scopeId: normalizeOptionId(id),
- priority: defaultAttendanceRuleScopePriority(type) || index + 1,
- scopeName: option?.name || '',
- };
- });
- this.$set(this.form, 'scopes', scopes);
- const first = scopes[0];
- this.form.scopeType = type;
- this.form.scopeId = first?.scopeId ?? '';
- this.form.scope = first?.scopeId ?? '';
- },
- hydrateScopeNames() {
- const optionMap = (this.scopeOptions || []).reduce((map, item) => {
- map[String(item.id)] = item;
- return map;
- }, {});
- (this.form.scopes || []).forEach((item) => {
- if (!item || item.scopeName) return;
- const option = optionMap[String(item.scopeId)];
- if (option?.name) item.scopeName = option.name;
- });
- },
- async loadScopeOptions(type, force = false) {
- if (!type) {
- this.scopeOptions = [];
- return;
- }
- if (!force && this.optionCache[type]) {
- this.scopeOptions = this.optionCache[type];
- this.alignSelectedIds();
- return;
- }
- this.scopeOptionsLoading = true;
- try {
- let options = [];
- if (type === 'EMPLOYEE') {
- options = await this.loadEmployeeOptions();
- } else if (type === 'POSITION') {
- options = await this.loadPositionOptions();
- } else {
- options = await this.loadOrganizationOptions(type);
- }
- this.optionCache[type] = options;
- this.scopeOptions = options;
- this.alignSelectedIds();
- } catch (error) {
- this.scopeOptions = [];
- } finally {
- this.scopeOptionsLoading = false;
- }
- },
- alignSelectedIds() {
- if (!this.selectedScopeIds.length || !this.scopeOptions.length) return;
- const optionIds = this.scopeOptions.map((item) => String(item.id));
- const next = this.selectedScopeIds
- .map((id) => {
- const hit = this.scopeOptions.find((item) => String(item.id) === String(id));
- return hit ? hit.id : id;
- })
- .filter((id) => optionIds.includes(String(id)));
- this.selectedScopeIds = next;
- },
- async loadEmployeeOptions() {
- const data = await fetchAllUsers({ isQueryLZ: 0 }, { pageSize: 200, maxPages: 30 });
- return (data.list || []).map((user) => ({
- id: normalizeOptionId(user.id),
- name: `${user.name || '未命名'}${user.jobNumber ? `(${user.jobNumber})` : ''}`,
- }));
- },
- async loadPositionOptions() {
- const data = await getPositionPage({ pageNum: 1, size: 1000, status: 1 });
- return (data?.list || []).map((item) => ({
- id: normalizeOptionId(item.id ?? item.positionId),
- name: item.positionName || item.name || '未命名岗位',
- }));
- },
- async loadOrganizationOptions(type) {
- // ORG(机构):只取登录缓存机构列表(用户所属机构,如「中赢合力」),
- // 与列表页「所属机构」筛选同一口径;之前误用全量组织树,把所有公司/部门都列了出来
- if (type === 'ORG') {
- const cached = getOrgOptions();
- if (cached.length) {
- return cached.map((item) => ({
- id: normalizeOptionId(item.id),
- name: item.name || '未命名组织',
- }));
- }
- // 登录缓存为空时兜底走全量组织树,避免下拉无选项
- }
- /*
- * DEPT(部门):只列「当前登录用户所属机构」下的部门(含各级子部门),
- * 不再把其它机构的部门列出来。
- * 需要 parentId 做层级下钻,而父组件传下来的 orgOptions 只有 { value, label, type },
- * 因此这里直接读扁平组织列表(listOrganizations 带 parentId,且有 1 分钟共享缓存)。
- */
- if (type === 'DEPT') {
- const { list: scoped, nameById } = await this.loadDepartmentsUnderCurrentOrg();
- if (scoped.length) {
- /* 编辑旧单据时,已选部门若不在「当前机构下」的列表里,补一条用于回显,
- 否则 el-select 找不到选项会退化成直接显示原始 id。 */
- const selected = (this.selectedScopeIds || []).map((id) => normalizeOptionId(id));
- const missing = selected.filter(
- (id) => id && !scoped.some((item) => String(item.id) === id),
- );
- return missing.length
- ? scoped.concat(
- missing.map((id) => ({ id, name: nameById[id] || id })),
- )
- : scoped;
- }
- // 拿不到层级关系(parentId 缺失)时,退回下面「全量 type=30」的兜底逻辑
- }
- let list = [];
- if (Array.isArray(this.orgOptions) && this.orgOptions.length) {
- list = this.orgOptions.map((item) => ({
- id: normalizeOptionId(item.value ?? item.id),
- name: item.label || item.name || '未命名组织',
- type: item.type,
- }));
- } else {
- const raw = await listOrganizations();
- list = (raw || []).map((item) => ({
- id: normalizeOptionId(item.id),
- name: item.name || '未命名组织',
- type: item.type,
- }));
- }
- const allowedTypes = ORG_TYPE_BY_SCOPE[type];
- if (allowedTypes) {
- const filtered = list.filter((item) => allowedTypes.includes(String(item.type)));
- // FACTORY:组织树里没有 type=40 的工厂节点时,兜底请求工厂区域接口
- // (/main/factoryarea/page?type=1,与员工档案「工厂」下拉同一数据源)
- if (type === 'FACTORY' && !filtered.length) {
- return this.loadFactoryAreaOptions();
- }
- return filtered;
- }
- // ORG:机构,展示全部组织单元
- return list;
- },
- /**
- * 「当前登录用户所属机构」下的所有部门(含各级子部门)
- * 机构取登录缓存 loginChangeGroupVOList(getOrgOptions),
- * 部门按扁平组织列表的 parentId 逐级下钻,只收集 type=30 的节点;
- * 机构自身、以及其它机构的部门都不返回。
- */
- async loadDepartmentsUnderCurrentOrg() {
- const raw = await listOrganizations();
- const rows = Array.isArray(raw) ? raw : raw?.list || raw?.records || raw?.rows || [];
- const nodes = rows
- .filter((item) => item && item.id != null && item.id !== '')
- .map((item) => ({
- id: normalizeOptionId(item.id),
- name: item.name || '未命名部门',
- parentId:
- item.parentId != null && item.parentId !== ''
- ? normalizeOptionId(item.parentId)
- : '',
- type: item.type,
- }));
- const orgIds = getOrgOptions().map((item) => normalizeOptionId(item.id));
- if (!orgIds.length || !nodes.length) return { list: [], nameById: {} };
- /* 全量部门名称索引:仅用于「已选值不在当前机构下」时的回显兜底 */
- const nameById = nodes.reduce((map, node) => {
- if (String(node.type) === '30') map[node.id] = node.name;
- return map;
- }, {});
- /* 只按 parentId 建索引:没有 parentId 的节点无法判断归属,直接不参与下钻 */
- const childrenOf = new Map();
- nodes.forEach((node) => {
- if (!node.parentId) return;
- if (!childrenOf.has(node.parentId)) childrenOf.set(node.parentId, []);
- childrenOf.get(node.parentId).push(node);
- });
- const result = [];
- const visited = new Set();
- const walk = (parentId) => {
- (childrenOf.get(parentId) || []).forEach((node) => {
- if (visited.has(node.id)) return;
- visited.add(node.id);
- /* 机构本身不作为部门选项;命中部门(type=30)后仍继续下钻子部门 */
- if (String(node.type) === '30') {
- result.push({ id: node.id, name: node.name });
- }
- walk(node.id);
- });
- };
- orgIds.forEach((id) => walk(id));
- return { list: result, nameById };
- },
- /** 工厂区域列表(type=1 工厂),组织树无工厂节点时的兜底数据源 */
- async loadFactoryAreaOptions() {
- const data = await getFactoryAreaPage({ pageNum: 1, size: 999, type: 1 });
- const rows = Array.isArray(data?.list) ? data.list : Array.isArray(data) ? data : [];
- return rows
- .map((item) => ({
- id: normalizeOptionId(item.id),
- name: item.name || item.factoryName || item.pathName || `工厂 ${item.id}`,
- }))
- .filter((item) => item.id != null && item.id !== '' && item.name);
- },
- validate(callback) {
- return this.$refs.form?.validate(callback);
- },
- validateField(prop) {
- return this.$refs.form?.validateField?.(prop);
- },
- },
- };
- </script>
- <style scoped>
- .scope-type-select,
- .scope-target-select {
- width: 100%;
- }
- .required {
- margin-right: 2px;
- color: #f56c6c;
- }
- </style>
|