zjk
2026-07-31 8665daa7aa863f96d0d8f27c167edae1efc38022
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
<template>
  <main class="config-page">
    <section class="config-card">
      <aside class="config-nav" aria-label="信息配置菜单">
        <h1>信息配置</h1>
        <button
          v-for="tab in tabs"
          :key="tab.value"
          class="config-nav-item"
          :class="{ active: activeTab === tab.value }"
          type="button"
          @click="activeTab = tab.value"
        >
          <svg-icon class-name="config-nav-icon" :icon-class="tab.icon" />
          <span>{{ tab.label }}</span>
        </button>
      </aside>
 
      <form class="config-workspace" @submit.prevent="submitForm">
        <section v-show="activeTab === 'register'">
          <div class="config-section">
            <h2>登记模式设置</h2>
            <label class="config-switch-row">
              <svg-icon class-name="row-icon" icon-class="config-onsite-register" />
              <span class="row-label">现场登记</span>
              <input v-model="form.onsiteRegistrationEnabled" type="checkbox">
              <span class="switch-control" />
            </label>
            <label class="config-switch-row">
              <svg-icon class-name="row-icon" icon-class="config-appointment-register" />
              <span class="row-label">预约登记</span>
              <input v-model="form.appointmentRegistrationEnabled" type="checkbox">
              <span class="switch-control" />
            </label>
            <div v-if="form.appointmentRegistrationEnabled" class="appointment-time-row">
              <svg-icon class-name="row-icon" icon-class="config-appointment-time" />
              <span class="row-label">预约登记默认访问时间</span>
              <span class="time-day">当天</span>
              <el-time-picker
                v-model="form.appointmentDefaultVisitTime"
                class="appointment-time-input"
                value-format="HH:mm"
                format="HH:mm"
                placeholder="请选择时间"
                :clearable="false"
              />
            </div>
          </div>
 
          <div class="config-section">
            <h2>接待人验证开关</h2>
            <p>设置登记、签离时是否需要接待人确认。若关闭,访客认证后直接打印凭条/签离时自动回收门禁权限。</p>
            <label v-for="item in receiverSwitches" :key="item.prop" class="config-switch-row">
              <svg-icon class-name="row-icon" :icon-class="item.icon" />
              <span class="row-label">{{ item.label }}</span>
              <input v-model="form[item.prop]" type="checkbox">
              <span class="switch-control" />
            </label>
          </div>
 
          <div class="config-section">
            <h2>人脸验证方式</h2>
            <p>人像对比即身份证和人脸对比确保为同一人(手动录入身份证除外),人脸采集则采集访客人脸照片。</p>
            <label v-for="item in faceSwitches" :key="item.prop" class="config-switch-row">
              <svg-icon class-name="row-icon" :icon-class="item.icon" />
              <span class="row-label">{{ item.label }}</span>
              <input v-model="form[item.prop]" type="checkbox">
              <span class="switch-control" />
            </label>
          </div>
 
          <div class="config-section">
            <h2>身份证手动输入</h2>
            <p>关闭后仅可刷身份证登记/签离。强制人脸比对时,开启则支持手动输入身份证并做人脸采集。</p>
            <label class="config-switch-row">
              <svg-icon class-name="row-icon" icon-class="config-id-card" />
              <span class="row-label">允许手动输入身份证</span>
              <input v-model="form.manualIdCardEnabled" type="checkbox">
              <span class="switch-control" />
            </label>
          </div>
        </section>
 
        <section v-show="activeTab === 'print'">
          <div class="print-layout">
            <div class="print-settings">
              <div class="config-section">
                <h2>凭条样式配置</h2>
                <p>勾选需要在凭条上打印的字段,支持拖拽排序(至少勾选1个字段)</p>
                <draggable v-model="printFieldOptions" class="print-field-list" ghost-class="print-field-ghost" chosen-class="print-field-chosen" :delay="120" :animation="160" @end="syncPrintFieldsOrder">
                  <label v-for="item in printFieldOptions" :key="item.fieldCode" class="print-field-item">
                    <input v-model="form.printFields" type="checkbox" :value="item.fieldCode">
                    <span>{{ item.fieldName }}</span>
                  </label>
                </draggable>
                <button class="sort-hint" type="button">
                  <i class="el-icon-s-opportunity sort-hint-icon" aria-hidden="true" />
                  <span>长按选项拖拽排序</span>
                </button>
              </div>
            </div>
 
            <aside class="print-preview">
              <div class="preview-title">
                <span>效果预览</span>
                <button type="button" title="刷新预览" aria-label="刷新预览" @click="refreshPreview">
                  <i class="el-icon-refresh" aria-hidden="true" />
                </button>
              </div>
              <div class="preview-stage">
                <section class="ticket-preview" aria-label="登记凭证预览">
                  <h3>登记凭证</h3>
                  <div class="ticket-rows">
                    <div v-for="row in ticketRows" :key="row.label" class="ticket-row">
                      <strong>{{ row.label }}</strong>
                      <span>{{ row.value }}</span>
                    </div>
                  </div>
                </section>
              </div>
            </aside>
          </div>
        </section>
 
        <section v-show="activeTab === 'system'">
          <div class="system-grid">
            <div class="config-section system-basic">
              <h2>系统信息配置</h2>
              <div class="field-grid">
                <label class="input-field">
                  <span>系统名称</span>
                  <input v-model="form.systemName" type="text" placeholder="请输入系统名称">
                </label>
                <div class="input-field">
                  <span>Logo</span>
                  <label class="logo-upload">
                    <input v-hasPermi="['visitor:config:edit']" type="file" accept="image/*" :disabled="uploadingLogo" @change="handleLogoChange">
                    <span class="logo-upload-button">选择文件</span>
                    <span class="logo-file-name">{{ uploadingLogo ? '上传中' : logoFileName }}</span>
                  </label>
                </div>
              </div>
            </div>
 
            <div class="config-section system-info">
              <h2>信息配置</h2>
              <div class="field-grid">
                <label class="input-field">
                  <span>服务厂家</span>
                  <input v-model="form.serviceProvider" type="text" placeholder="请输入服务厂家">
                </label>
                <label class="input-field">
                  <span>技术支持电话</span>
                  <input v-model="form.supportPhone" type="text" placeholder="请输入技术支持电话">
                </label>
              </div>
            </div>
 
            <div class="config-section system-entry">
              <h2>入口配置</h2>
              <p>关闭后入口不显示,但是仍可以点击该区域进入。</p>
              <label class="config-switch-row">
                <svg-icon class-name="row-icon" icon-class="config-device" />
                <span class="row-label">访客机管理侧入口</span>
                <input v-model="form.visitorMachineEntryEnabled" type="checkbox">
                <span class="switch-control" />
              </label>
            </div>
          </div>
        </section>
 
        <footer class="config-actions">
          <button v-hasPermi="['visitor:config:edit']" class="config-save-button" type="submit" :disabled="saving">{{ saving ? '保存中' : '保存' }}</button>
        </footer>
      </form>
    </section>
  </main>
</template>
 
<script>
import draggable from 'vuedraggable'
import { getVisitorConfig, saveVisitorConfig, uploadVisitorLogo } from '@/api/visitor-admin/config'
import { getResponseData, normalizeBoolean } from '@/utils/visitor-admin'
import { setVisitorBrand } from '@/utils/visitor-brand'
 
const defaultForm = {
  onsiteRegistrationEnabled: true,
  appointmentRegistrationEnabled: true,
  appointmentDefaultVisitTime: '',
  registerReceiverConfirmEnabled: false,
  signReceiverConfirmEnabled: false,
  faceCompareEnabled: false,
  faceCaptureEnabled: false,
  manualIdCardEnabled: true,
  visitorMachineEntryEnabled: true,
  printFields: ['visitorName', 'idCard', 'intervieweeName', 'visitAreaNames', 'validTime'],
  systemName: '',
  logoUrl: '',
  serviceProvider: '',
  supportPhone: ''
}
 
export default {
  name: 'VisitorSystemConfig',
  components: { draggable },
  // 目的:初始化系统配置表单、配置页签、开关项和打印字段选项。
  data() {
    return {
      saving: false,
      uploadingLogo: false,
      activeTab: 'register',
      logoFileName: '未选择任何文件',
      form: { ...defaultForm },
      tabs: [
        { value: 'register', label: '登记配置', icon: 'config-register' },
        { value: 'print', label: '打印凭条配置', icon: 'config-print' },
        { value: 'system', label: '系统信息', icon: 'config-system' }
      ],
      receiverSwitches: [
        { prop: 'registerReceiverConfirmEnabled', label: '登记需接待人确认', icon: 'config-receiver-confirm' },
        { prop: 'signReceiverConfirmEnabled', label: '签离需接待人确认', icon: 'config-sign-confirm' }
      ],
      faceSwitches: [
        { prop: 'faceCompareEnabled', label: '人像对比', icon: 'config-face-compare' },
        { prop: 'faceCaptureEnabled', label: '人脸采集', icon: 'config-face-capture' }
      ],
      printFieldOptions: [
        { fieldCode: 'visitorName', fieldName: '访客姓名' },
        { fieldCode: 'idCard', fieldName: '证件号码' },
        { fieldCode: 'intervieweeName', fieldName: '被访人' },
        { fieldCode: 'visitAreaNames', fieldName: '访问区域' },
        { fieldCode: 'validTime', fieldName: '有效期' },
        { fieldCode: 'plateNo', fieldName: '车牌号' },
        { fieldCode: 'visitReason', fieldName: '来访事由' },
        { fieldCode: 'receiverName', fieldName: '接待人' },
        { fieldCode: 'registerTime', fieldName: '登记时间' }
      ]
    }
  },
  computed: {
    // 目的:根据勾选的打印字段生成右侧访客凭条预览行。
    ticketRows() {
      const sample = {
        visitorName: '张三',
        idCard: '320***********001',
        intervieweeName: '李四',
        visitAreaNames: 'A区 / 办公楼',
        validTime: '2026-07-07 09:00~18:00',
        plateNo: '苏A12345',
        visitReason: '商务拜访',
        receiverName: '王五',
        registerTime: '2026-07-23 09:00'
      }
      return this.printFieldOptions
        .filter(item => this.form.printFields.indexOf(item.fieldCode) !== -1)
        .map(item => ({ label: item.fieldName, value: sample[item.fieldCode] || '-' }))
    }
  },
  // 目的:页面创建时加载当前访客系统配置。
  created() {
    this.getDetail()
  },
  methods: {
    // 目的:把后端布尔兼容值转换为开关组件使用的布尔值。
    boolValue(value, fallback) {
      return value === undefined || value === null ? fallback : normalizeBoolean(value)
    },
    // 目的:加载系统配置详情,并用默认配置兜底缺失字段。
    getDetail() {
      getVisitorConfig().then(res => {
        const data = getResponseData(res) || {}
        const printFields = Array.isArray(data.printFields) && data.printFields.length ? data.printFields : defaultForm.printFields
        this.form = {
          ...defaultForm,
          ...data,
          printFields,
          onsiteRegistrationEnabled: this.boolValue(data.onsiteRegistrationEnabled, defaultForm.onsiteRegistrationEnabled),
          appointmentRegistrationEnabled: this.boolValue(data.appointmentRegistrationEnabled, defaultForm.appointmentRegistrationEnabled),
          registerReceiverConfirmEnabled: this.boolValue(data.registerReceiverConfirmEnabled, defaultForm.registerReceiverConfirmEnabled),
          signReceiverConfirmEnabled: this.boolValue(data.signReceiverConfirmEnabled, defaultForm.signReceiverConfirmEnabled),
          faceCompareEnabled: this.boolValue(data.faceCompareEnabled, defaultForm.faceCompareEnabled),
          faceCaptureEnabled: this.boolValue(data.faceCaptureEnabled, defaultForm.faceCaptureEnabled),
          manualIdCardEnabled: this.boolValue(data.manualIdCardEnabled, defaultForm.manualIdCardEnabled),
          visitorMachineEntryEnabled: this.boolValue(data.visitorMachineEntryEnabled, defaultForm.visitorMachineEntryEnabled)
        }
        if (data.printFieldOptions && data.printFieldOptions.length) {
          this.printFieldOptions = this.applyPrintFieldOrder(printFields, data.printFieldOptions)
        } else {
          this.printFieldOptions = this.applyPrintFieldOrder(printFields, this.printFieldOptions)
        }
        this.syncPrintFieldsOrder()
        this.syncBrand(this.form)
      })
    },
    // 目的:按已保存的打印字段顺序重排字段选项,保证页面初始顺序和实际打印顺序一致。
    applyPrintFieldOrder(printFields, options) {
      const optionMap = new Map((options || []).map(item => [item.fieldCode, item]))
      const ordered = (printFields || []).map(fieldCode => optionMap.get(fieldCode)).filter(Boolean)
      const remaining = (options || []).filter(item => (printFields || []).indexOf(item.fieldCode) === -1)
      return ordered.concat(remaining)
    },
    // 目的:把当前拖拽后的字段顺序同步到表单,保存时后端按该数组顺序打印。
    syncPrintFieldsOrder() {
      this.form.printFields = this.printFieldOptions
        .filter(item => this.form.printFields.indexOf(item.fieldCode) !== -1)
        .map(item => item.fieldCode)
    },
    // 目的:校验并上传 Logo 图片,成功后同步系统品牌预览。
    handleLogoChange(event) {
      const file = event.target.files && event.target.files[0]
      this.logoFileName = file ? file.name : '未选择任何文件'
      if (!file) {
        return
      }
      if (!file.type || file.type.indexOf('image/') !== 0) {
        event.target.value = ''
        this.logoFileName = '未选择任何文件'
        this.$modal.msgError('请选择图片文件')
        return
      }
      const formData = new FormData()
      formData.append('file', file)
      this.uploadingLogo = true
      uploadVisitorLogo(formData).then(res => {
        const data = getResponseData(res) || {}
        const logoUrl = data.fileName || data.url
        if (!logoUrl) {
          this.$modal.msgError('Logo上传失败,未返回文件地址')
          return
        }
        this.form.logoUrl = logoUrl
        this.syncBrand(this.form)
        this.$modal.msgSuccess('Logo上传成功')
      }).finally(() => {
        this.uploadingLogo = false
        event.target.value = ''
      })
    },
    // 目的:提示当前配置预览已刷新,保留显式刷新入口。
    refreshPreview() {
      this.$modal.msgSuccess('预览已刷新')
    },
    // 目的:校验并保存系统配置,同时同步页面品牌信息。
    submitForm() {
      if (!this.form.printFields.length) {
        this.$modal.msgError('至少选择一个打印字段')
        this.activeTab = 'print'
        return
      }
      this.saving = true
      const payload = {
        ...this.form,
        printFields: this.printFieldOptions
          .filter(item => this.form.printFields.indexOf(item.fieldCode) !== -1)
          .map(item => item.fieldCode)
      }
      saveVisitorConfig(payload).then(res => {
        const data = getResponseData(res) || this.form
        this.form = { ...this.form, ...data }
        this.syncBrand(this.form)
        this.$modal.msgSuccess('保存成功')
      }).finally(() => {
        this.saving = false
      })
    },
    // 目的:把系统名称和 Logo 写入全局访客后台品牌缓存。
    syncBrand(config) {
      setVisitorBrand({
        systemName: config.systemName,
        logoUrl: config.logoUrl
      })
    }
  }
}
</script>