Browse Source

feat: 新增台账管理与配分体系前端页面

- 新增 src/api/ledger/index.js:台账导入、列表、导出、增删改查接口
- 新增 src/api/score/index.js:配分维度/指标/事项/推送配置/雷达图接口
- 新增台账一键导入页(含一键全量导入入口 + 12类单独导入卡片)
- 新增15个台账查询/录入页面(supervisionProblem、patrolInspection 等)
- 新增配分体系维护页(维度卡片 + 指标树)
- 新增配分事项录入页(三/四级联动下拉)
- 新增推送配置页
- 新增班组成员画像雷达图大屏(含同步台账按钮)
- 修复 request.js:FormData 上传时自动清除全局 Content-Type,避免 multipart 请求失败
simonlll 1 month ago
parent
commit
c3a311030d

+ 203 - 0
src/api/ledger/index.js

@@ -0,0 +1,203 @@
1
+import request from '@/utils/request'
2
+
3
+// ===== 台账一键全量导入(多Sheet合并Excel)=====
4
+export function importCombinedLedger(data) {
5
+  return request({ url: '/ledger/import/combined', method: 'post', data })
6
+}
7
+
8
+// ===== 台账一键导入 =====
9
+export function importSupervisionProblem(data) {
10
+  return request({ url: '/ledger/import/supervisionProblem', method: 'post', data })
11
+}
12
+export function importPatrolInspection(data) {
13
+  return request({ url: '/ledger/import/patrolInspection', method: 'post', data })
14
+}
15
+export function importRealtimeInterception(data) {
16
+  return request({ url: '/ledger/import/realtimeInterception', method: 'post', data })
17
+}
18
+export function importServicePatrol(data) {
19
+  return request({ url: '/ledger/import/servicePatrol', method: 'post', data })
20
+}
21
+export function importComplaint(data) {
22
+  return request({ url: '/ledger/import/complaint', method: 'post', data })
23
+}
24
+export function importSecurityTest(data) {
25
+  return request({ url: '/ledger/import/securityTest', method: 'post', data })
26
+}
27
+export function importChannelPassRate(data) {
28
+  return request({ url: '/ledger/import/channelPassRate', method: 'post', data })
29
+}
30
+export function importUnsafeEvent(data) {
31
+  return request({ url: '/ledger/import/unsafeEvent', method: 'post', data })
32
+}
33
+export function importSeizureStats(data) {
34
+  return request({ url: '/ledger/import/seizureStats', method: 'post', data })
35
+}
36
+export function importTerminalBonus(data) {
37
+  return request({ url: '/ledger/import/terminalBonus', method: 'post', data })
38
+}
39
+export function importExamScore(data) {
40
+  return request({ url: '/ledger/import/examScore', method: 'post', data })
41
+}
42
+export function importRewardApproval(data) {
43
+  return request({ url: '/ledger/import/rewardApproval', method: 'post', data })
44
+}
45
+
46
+// ===== 部门监察问题记录 =====
47
+export function listSupervisionProblem(query) {
48
+  return request({ url: '/ledger/supervisionProblem/list', method: 'get', params: query })
49
+}
50
+export function getSupervisionProblem(id) {
51
+  return request({ url: '/ledger/supervisionProblem/' + id, method: 'get' })
52
+}
53
+export function exportSupervisionProblem(query) {
54
+  return request({ url: '/ledger/supervisionProblem/export', method: 'post', params: query, responseType: 'blob' })
55
+}
56
+
57
+// ===== 队室三级质控巡查记录 =====
58
+export function listPatrolInspection(query) {
59
+  return request({ url: '/ledger/patrolInspection/list', method: 'get', params: query })
60
+}
61
+export function exportPatrolInspection(query) {
62
+  return request({ url: '/ledger/patrolInspection/export', method: 'post', params: query, responseType: 'blob' })
63
+}
64
+
65
+// ===== 部门实时质控拦截记录 =====
66
+export function listRealtimeInterception(query) {
67
+  return request({ url: '/ledger/realtimeInterception/list', method: 'get', params: query })
68
+}
69
+export function exportRealtimeInterception(query) {
70
+  return request({ url: '/ledger/realtimeInterception/export', method: 'post', params: query, responseType: 'blob' })
71
+}
72
+
73
+// ===== 服务巡查记录 =====
74
+export function listServicePatrol(query) {
75
+  return request({ url: '/ledger/servicePatrol/list', method: 'get', params: query })
76
+}
77
+export function exportServicePatrol(query) {
78
+  return request({ url: '/ledger/servicePatrol/export', method: 'post', params: query, responseType: 'blob' })
79
+}
80
+
81
+// ===== 投诉情况记录 =====
82
+export function listComplaint(query) {
83
+  return request({ url: '/ledger/complaint/list', method: 'get', params: query })
84
+}
85
+export function exportComplaint(query) {
86
+  return request({ url: '/ledger/complaint/export', method: 'post', params: query, responseType: 'blob' })
87
+}
88
+
89
+// ===== 安保测试记录 =====
90
+export function listSecurityTest(query) {
91
+  return request({ url: '/ledger/securityTest/list', method: 'get', params: query })
92
+}
93
+export function exportSecurityTest(query) {
94
+  return request({ url: '/ledger/securityTest/export', method: 'post', params: query, responseType: 'blob' })
95
+}
96
+
97
+// ===== 通道过检率记录 =====
98
+export function listChannelPassRate(query) {
99
+  return request({ url: '/ledger/channelPassRate/list', method: 'get', params: query })
100
+}
101
+export function exportChannelPassRate(query) {
102
+  return request({ url: '/ledger/channelPassRate/export', method: 'post', params: query, responseType: 'blob' })
103
+}
104
+
105
+// ===== 不安全事件记录 =====
106
+export function listUnsafeEvent(query) {
107
+  return request({ url: '/ledger/unsafeEvent/list', method: 'get', params: query })
108
+}
109
+export function exportUnsafeEvent(query) {
110
+  return request({ url: '/ledger/unsafeEvent/export', method: 'post', params: query, responseType: 'blob' })
111
+}
112
+
113
+// ===== 查获违规品统计 =====
114
+export function listSeizureStats(query) {
115
+  return request({ url: '/ledger/seizureStats/list', method: 'get', params: query })
116
+}
117
+export function exportSeizureStats(query) {
118
+  return request({ url: '/ledger/seizureStats/export', method: 'post', params: query, responseType: 'blob' })
119
+}
120
+
121
+// ===== 航站楼加分记录 =====
122
+export function listTerminalBonus(query) {
123
+  return request({ url: '/ledger/terminalBonus/list', method: 'get', params: query })
124
+}
125
+export function exportTerminalBonus(query) {
126
+  return request({ url: '/ledger/terminalBonus/export', method: 'post', params: query, responseType: 'blob' })
127
+}
128
+
129
+// ===== 成绩收集 =====
130
+export function listExamScore(query) {
131
+  return request({ url: '/ledger/examScore/list', method: 'get', params: query })
132
+}
133
+export function exportExamScore(query) {
134
+  return request({ url: '/ledger/examScore/export', method: 'post', params: query, responseType: 'blob' })
135
+}
136
+
137
+// ===== 小额奖励审批单 =====
138
+export function listRewardApproval(query) {
139
+  return request({ url: '/ledger/rewardApproval/list', method: 'get', params: query })
140
+}
141
+export function exportRewardApproval(query) {
142
+  return request({ url: '/ledger/rewardApproval/export', method: 'post', params: query, responseType: 'blob' })
143
+}
144
+
145
+// ===== 部门奖惩记录(增删改查) =====
146
+export function listRewardPenalty(query) {
147
+  return request({ url: '/ledger/rewardPenalty/list', method: 'get', params: query })
148
+}
149
+export function getRewardPenalty(id) {
150
+  return request({ url: '/ledger/rewardPenalty/' + id, method: 'get' })
151
+}
152
+export function addRewardPenalty(data) {
153
+  return request({ url: '/ledger/rewardPenalty', method: 'post', data })
154
+}
155
+export function updateRewardPenalty(data) {
156
+  return request({ url: '/ledger/rewardPenalty', method: 'put', data })
157
+}
158
+export function delRewardPenalty(ids) {
159
+  return request({ url: '/ledger/rewardPenalty/' + ids, method: 'delete' })
160
+}
161
+export function exportRewardPenalty(query) {
162
+  return request({ url: '/ledger/rewardPenalty/export', method: 'post', params: query, responseType: 'blob' })
163
+}
164
+
165
+// ===== 请休假记录(特殊)(增删改查) =====
166
+export function listLeaveSpecial(query) {
167
+  return request({ url: '/ledger/leaveSpecial/list', method: 'get', params: query })
168
+}
169
+export function getLeaveSpecial(id) {
170
+  return request({ url: '/ledger/leaveSpecial/' + id, method: 'get' })
171
+}
172
+export function addLeaveSpecial(data) {
173
+  return request({ url: '/ledger/leaveSpecial', method: 'post', data })
174
+}
175
+export function updateLeaveSpecial(data) {
176
+  return request({ url: '/ledger/leaveSpecial', method: 'put', data })
177
+}
178
+export function delLeaveSpecial(ids) {
179
+  return request({ url: '/ledger/leaveSpecial/' + ids, method: 'delete' })
180
+}
181
+export function exportLeaveSpecial(query) {
182
+  return request({ url: '/ledger/leaveSpecial/export', method: 'post', params: query, responseType: 'blob' })
183
+}
184
+
185
+// ===== 锦旗及感谢信记录(增删改查) =====
186
+export function listBannerLetter(query) {
187
+  return request({ url: '/ledger/bannerLetter/list', method: 'get', params: query })
188
+}
189
+export function getBannerLetter(id) {
190
+  return request({ url: '/ledger/bannerLetter/' + id, method: 'get' })
191
+}
192
+export function addBannerLetter(data) {
193
+  return request({ url: '/ledger/bannerLetter', method: 'post', data })
194
+}
195
+export function updateBannerLetter(data) {
196
+  return request({ url: '/ledger/bannerLetter', method: 'put', data })
197
+}
198
+export function delBannerLetter(ids) {
199
+  return request({ url: '/ledger/bannerLetter/' + ids, method: 'delete' })
200
+}
201
+export function exportBannerLetter(query) {
202
+  return request({ url: '/ledger/bannerLetter/export', method: 'post', params: query, responseType: 'blob' })
203
+}

+ 105 - 0
src/api/score/index.js

@@ -0,0 +1,105 @@
1
+import request from '@/utils/request'
2
+
3
+// ===== 评分维度 =====
4
+export function listDimension(params) {
5
+  return request({ url: '/score/dimension/list', method: 'get', params })
6
+}
7
+export function allDimension() {
8
+  return request({ url: '/score/dimension/all', method: 'get' })
9
+}
10
+export function getDimension(id) {
11
+  return request({ url: `/score/dimension/${id}`, method: 'get' })
12
+}
13
+export function addDimension(data) {
14
+  return request({ url: '/score/dimension', method: 'post', data })
15
+}
16
+export function updateDimension(data) {
17
+  return request({ url: '/score/dimension', method: 'put', data })
18
+}
19
+export function delDimension(ids) {
20
+  return request({ url: `/score/dimension/${ids}`, method: 'delete' })
21
+}
22
+export function exportDimension(params) {
23
+  return request({ url: '/score/dimension/export', method: 'post', params, responseType: 'blob' })
24
+}
25
+
26
+// ===== 评分指标 =====
27
+export function listIndicator(params) {
28
+  return request({ url: '/score/indicator/list', method: 'get', params })
29
+}
30
+export function treeIndicator(dimensionId) {
31
+  return request({ url: '/score/indicator/tree', method: 'get', params: { dimensionId } })
32
+}
33
+export function getIndicator(id) {
34
+  return request({ url: `/score/indicator/${id}`, method: 'get' })
35
+}
36
+export function addIndicator(data) {
37
+  return request({ url: '/score/indicator', method: 'post', data })
38
+}
39
+export function updateIndicator(data) {
40
+  return request({ url: '/score/indicator', method: 'put', data })
41
+}
42
+export function delIndicator(ids) {
43
+  return request({ url: `/score/indicator/${ids}`, method: 'delete' })
44
+}
45
+export function exportIndicator(params) {
46
+  return request({ url: '/score/indicator/export', method: 'post', params, responseType: 'blob' })
47
+}
48
+
49
+// ===== 配分事项 =====
50
+export function listScoreEvent(params) {
51
+  return request({ url: '/score/event/list', method: 'get', params })
52
+}
53
+export function getScoreEvent(id) {
54
+  return request({ url: `/score/event/${id}`, method: 'get' })
55
+}
56
+export function addScoreEvent(data) {
57
+  return request({ url: '/score/event', method: 'post', data })
58
+}
59
+export function updateScoreEvent(data) {
60
+  return request({ url: '/score/event', method: 'put', data })
61
+}
62
+export function delScoreEvent(ids) {
63
+  return request({ url: `/score/event/${ids}`, method: 'delete' })
64
+}
65
+export function exportScoreEvent(params) {
66
+  return request({ url: '/score/event/export', method: 'post', params, responseType: 'blob' })
67
+}
68
+export function importScoreEvent(file) {
69
+  const fd = new FormData()
70
+  fd.append('file', file)
71
+  return request({ url: '/score/event/import', method: 'post', data: fd })
72
+}
73
+
74
+// ===== 雷达图大屏 =====
75
+export function radarTeamList() {
76
+  return request({ url: '/score/radar/teamList', method: 'get' })
77
+}
78
+export function radarMemberScores(teamName) {
79
+  return request({ url: '/score/radar/memberScores', method: 'get', params: { teamName } })
80
+}
81
+
82
+// ===== 台账同步 =====
83
+export function syncLedgerAll() {
84
+  return request({ url: '/ledger/sync/all', method: 'post' })
85
+}
86
+export function syncLedgerByType(type) {
87
+  return request({ url: `/ledger/sync/${type}`, method: 'post' })
88
+}
89
+
90
+// ===== 推送配置 =====
91
+export function listPushConfig(params) {
92
+  return request({ url: '/score/pushConfig/list', method: 'get', params })
93
+}
94
+export function getPushConfig(id) {
95
+  return request({ url: `/score/pushConfig/${id}`, method: 'get' })
96
+}
97
+export function addPushConfig(data) {
98
+  return request({ url: '/score/pushConfig', method: 'post', data })
99
+}
100
+export function updatePushConfig(data) {
101
+  return request({ url: '/score/pushConfig', method: 'put', data })
102
+}
103
+export function delPushConfig(ids) {
104
+  return request({ url: `/score/pushConfig/${ids}`, method: 'delete' })
105
+}

+ 4 - 0
src/utils/request.js

@@ -23,6 +23,10 @@ const service = axios.create({
23 23
 
24 24
 // request拦截器
25 25
 service.interceptors.request.use(config => {
26
+  // FormData 上传时让浏览器自动设置 multipart/form-data(含 boundary),不能使用全局 JSON Content-Type
27
+  if (config.data instanceof FormData) {
28
+    delete config.headers['Content-Type']
29
+  }
26 30
   // 是否需要设置 token
27 31
   const isToken = (config.headers || {}).isToken === false
28 32
   // 是否需要防止数据重复提交

+ 161 - 0
src/views/ledger/bannerLetter/index.vue

@@ -0,0 +1,161 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="记录日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+
20
+    <el-row :gutter="10" class="mb8">
21
+      <el-col :span="1.5">
22
+        <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['ledger:bannerLetter:add']">新增</el-button>
23
+      </el-col>
24
+      <el-col :span="1.5">
25
+        <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['ledger:bannerLetter:edit']">修改</el-button>
26
+      </el-col>
27
+      <el-col :span="1.5">
28
+        <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['ledger:bannerLetter:remove']">删除</el-button>
29
+      </el-col>
30
+      <el-col :span="1.5">
31
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:bannerLetter:export']">导出</el-button>
32
+      </el-col>
33
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
34
+    </el-row>
35
+
36
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange">
37
+      <el-table-column type="selection" width="55" align="center" />
38
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
39
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
40
+      </el-table-column>
41
+      <el-table-column label="部门名称" align="center" prop="deptName" />
42
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
43
+      <el-table-column label="姓名" align="center" prop="personName" />
44
+      <el-table-column label="类型" align="center" prop="type">
45
+        <template #default="{ row }">
46
+          <el-tag :type="row.type === '1' ? 'warning' : 'success'">{{ row.type === '1' ? '锦旗' : '感谢信' }}</el-tag>
47
+        </template>
48
+      </el-table-column>
49
+      <el-table-column label="赠予方" align="center" prop="giver" />
50
+      <el-table-column label="内容描述" align="center" prop="contentDesc" show-overflow-tooltip />
51
+      <el-table-column label="加分" align="center" prop="addScore">
52
+        <template #default="{ row }"><span v-if="row.addScore" style="color:#67c23a">+{{ row.addScore }}</span></template>
53
+      </el-table-column>
54
+      <el-table-column label="附件" align="center" prop="evidenceFile">
55
+        <template #default="{ row }">
56
+          <el-link v-if="row.evidenceFile" :href="row.evidenceFile" target="_blank" type="primary">查看</el-link>
57
+          <span v-else>-</span>
58
+        </template>
59
+      </el-table-column>
60
+      <el-table-column label="操作" align="center" width="120">
61
+        <template #default="{ row }">
62
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['ledger:bannerLetter:edit']">修改</el-button>
63
+          <el-button link type="primary" icon="Delete" @click="handleDelete(row)" v-hasPermi="['ledger:bannerLetter:remove']">删除</el-button>
64
+        </template>
65
+      </el-table-column>
66
+    </el-table>
67
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
68
+
69
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" append-to-body>
70
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
71
+        <el-form-item label="记录日期" prop="recordDate">
72
+          <el-date-picker v-model="form.recordDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择记录日期" style="width:100%" />
73
+        </el-form-item>
74
+        <el-form-item label="部门名称" prop="deptName">
75
+          <el-input v-model="form.deptName" placeholder="请输入部门名称" />
76
+        </el-form-item>
77
+        <el-form-item label="队室/班组" prop="teamName">
78
+          <el-input v-model="form.teamName" placeholder="请输入队室/班组" />
79
+        </el-form-item>
80
+        <el-form-item label="姓名" prop="personName">
81
+          <el-input v-model="form.personName" placeholder="请输入姓名" />
82
+        </el-form-item>
83
+        <el-form-item label="类型" prop="type">
84
+          <el-radio-group v-model="form.type">
85
+            <el-radio value="1">锦旗</el-radio>
86
+            <el-radio value="2">感谢信</el-radio>
87
+          </el-radio-group>
88
+        </el-form-item>
89
+        <el-form-item label="赠予方" prop="giver">
90
+          <el-input v-model="form.giver" placeholder="请输入赠予方" />
91
+        </el-form-item>
92
+        <el-form-item label="内容描述" prop="contentDesc">
93
+          <el-input v-model="form.contentDesc" type="textarea" :rows="3" placeholder="请输入内容描述" />
94
+        </el-form-item>
95
+        <el-form-item label="加分" prop="addScore">
96
+          <el-input-number v-model="form.addScore" :precision="2" :min="0" style="width:100%" />
97
+        </el-form-item>
98
+        <el-form-item label="备注" prop="remark">
99
+          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
100
+        </el-form-item>
101
+      </el-form>
102
+      <template #footer>
103
+        <el-button @click="dialogVisible = false">取消</el-button>
104
+        <el-button type="primary" @click="submitForm">确定</el-button>
105
+      </template>
106
+    </el-dialog>
107
+  </div>
108
+</template>
109
+
110
+<script setup>
111
+import { ref, reactive, onMounted } from 'vue'
112
+import { ElMessage, ElMessageBox } from 'element-plus'
113
+import { listBannerLetter, addBannerLetter, updateBannerLetter, delBannerLetter, exportBannerLetter } from '@/api/ledger/index'
114
+import { parseTime } from '@/utils/ruoyi'
115
+
116
+defineOptions({ name: 'LedgerBannerLetter' })
117
+
118
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
119
+const dateRange = ref([]), queryRef = ref(null), formRef = ref(null)
120
+const dialogVisible = ref(false), dialogTitle = ref('')
121
+const single = ref(true), multiple = ref(true), ids = ref([])
122
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
123
+const form = reactive({ id: null, recordDate: '', deptName: '', teamName: '', personName: '', type: '1', giver: '', contentDesc: '', addScore: 0, evidenceFile: '', remark: '' })
124
+const rules = { personName: [{ required: true, message: '请输入姓名', trigger: 'blur' }], type: [{ required: true, message: '请选择类型', trigger: 'change' }] }
125
+
126
+function getList() {
127
+  loading.value = true
128
+  const p = { ...queryParams }
129
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
130
+  listBannerLetter(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
131
+}
132
+function handleQuery() { queryParams.pageNum = 1; getList() }
133
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
134
+function handleSelectionChange(sel) { ids.value = sel.map(s => s.id); single.value = sel.length !== 1; multiple.value = !sel.length }
135
+function resetForm() { Object.assign(form, { id: null, recordDate: '', deptName: '', teamName: '', personName: '', type: '1', giver: '', contentDesc: '', addScore: 0, evidenceFile: '', remark: '' }) }
136
+function handleAdd() { resetForm(); dialogTitle.value = '新增锦旗/感谢信'; dialogVisible.value = true }
137
+function handleUpdate(row) {
138
+  resetForm()
139
+  const record = row || list.value.find(r => r.id === ids.value[0])
140
+  if (record) Object.assign(form, record)
141
+  dialogTitle.value = '修改锦旗/感谢信'; dialogVisible.value = true
142
+}
143
+async function submitForm() {
144
+  await formRef.value.validate()
145
+  if (form.id) { await updateBannerLetter(form); ElMessage.success('修改成功') }
146
+  else { await addBannerLetter(form); ElMessage.success('新增成功') }
147
+  dialogVisible.value = false; getList()
148
+}
149
+function handleDelete(row) {
150
+  const delIds = row?.id ? [row.id] : ids.value
151
+  ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' }).then(() => {
152
+    delBannerLetter(delIds.join(',')).then(() => { ElMessage.success('删除成功'); getList() })
153
+  })
154
+}
155
+function handleExport() {
156
+  const p = { ...queryParams }
157
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
158
+  exportBannerLetter(p)
159
+}
160
+onMounted(getList)
161
+</script>

+ 61 - 0
src/views/ledger/channelPassRate/index.vue

@@ -0,0 +1,61 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="记录日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+    <el-row :gutter="10" class="mb8">
20
+      <el-col :span="1.5">
21
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:channelPassRate:export']">导出</el-button>
22
+      </el-col>
23
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
24
+    </el-row>
25
+    <el-table v-loading="loading" :data="list">
26
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
27
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
28
+      </el-table-column>
29
+      <el-table-column label="部门名称" align="center" prop="deptName" />
30
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
31
+      <el-table-column label="通道号" align="center" prop="channelNo" />
32
+      <el-table-column label="总过检人数" align="center" prop="totalCount" />
33
+      <el-table-column label="过检人数" align="center" prop="passCount" />
34
+      <el-table-column label="过检率(%)" align="center" prop="passRate" />
35
+    </el-table>
36
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
37
+  </div>
38
+</template>
39
+<script setup>
40
+import { ref, reactive, onMounted } from 'vue'
41
+import { listChannelPassRate, exportChannelPassRate } from '@/api/ledger/index'
42
+import { parseTime } from '@/utils/ruoyi'
43
+defineOptions({ name: 'LedgerChannelPassRate' })
44
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
45
+const dateRange = ref([]), queryRef = ref(null)
46
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
47
+function getList() {
48
+  loading.value = true
49
+  const p = { ...queryParams }
50
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
51
+  listChannelPassRate(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
52
+}
53
+function handleQuery() { queryParams.pageNum = 1; getList() }
54
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
55
+function handleExport() {
56
+  const p = { ...queryParams }
57
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
58
+  exportChannelPassRate(p)
59
+}
60
+onMounted(getList)
61
+</script>

+ 75 - 0
src/views/ledger/complaint/index.vue

@@ -0,0 +1,75 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="航班号" prop="flightNo">
11
+        <el-input v-model="queryParams.flightNo" placeholder="请输入航班号" clearable @keyup.enter="handleQuery" />
12
+      </el-form-item>
13
+      <el-form-item label="旅客姓名" prop="passengerName">
14
+        <el-input v-model="queryParams.passengerName" placeholder="请输入旅客姓名" clearable @keyup.enter="handleQuery" />
15
+      </el-form-item>
16
+      <el-form-item label="责任人" prop="responsibleName">
17
+        <el-input v-model="queryParams.responsibleName" placeholder="请输入责任人" clearable @keyup.enter="handleQuery" />
18
+      </el-form-item>
19
+      <el-form-item label="记录日期">
20
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
21
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
22
+      </el-form-item>
23
+      <el-form-item>
24
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
25
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
26
+      </el-form-item>
27
+    </el-form>
28
+    <el-row :gutter="10" class="mb8">
29
+      <el-col :span="1.5">
30
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:complaint:export']">导出</el-button>
31
+      </el-col>
32
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
33
+    </el-row>
34
+    <el-table v-loading="loading" :data="list">
35
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
36
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
37
+      </el-table-column>
38
+      <el-table-column label="部门名称" align="center" prop="deptName" />
39
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
40
+      <el-table-column label="责任人" align="center" prop="responsibleName" />
41
+      <el-table-column label="航班号" align="center" prop="flightNo" />
42
+      <el-table-column label="旅客姓名" align="center" prop="passengerName" />
43
+      <el-table-column label="投诉类型" align="center" prop="complaintType" />
44
+      <el-table-column label="投诉内容" align="center" prop="complaintDesc" show-overflow-tooltip />
45
+      <el-table-column label="处理结果" align="center" prop="resultHandling" show-overflow-tooltip />
46
+      <el-table-column label="扣分" align="center" prop="deductScore">
47
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{ row.deductScore }}</span></template>
48
+      </el-table-column>
49
+    </el-table>
50
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
51
+  </div>
52
+</template>
53
+<script setup>
54
+import { ref, reactive, onMounted } from 'vue'
55
+import { listComplaint, exportComplaint } from '@/api/ledger/index'
56
+import { parseTime } from '@/utils/ruoyi'
57
+defineOptions({ name: 'LedgerComplaint' })
58
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
59
+const dateRange = ref([]), queryRef = ref(null)
60
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '', flightNo: '', passengerName: '', responsibleName: '' })
61
+function getList() {
62
+  loading.value = true
63
+  const p = { ...queryParams }
64
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
65
+  listComplaint(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
66
+}
67
+function handleQuery() { queryParams.pageNum = 1; getList() }
68
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
69
+function handleExport() {
70
+  const p = { ...queryParams }
71
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
72
+  exportComplaint(p)
73
+}
74
+onMounted(getList)
75
+</script>

+ 74 - 0
src/views/ledger/examScore/index.vue

@@ -0,0 +1,74 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="小组" prop="groupName">
11
+        <el-input v-model="queryParams.groupName" placeholder="请输入小组" clearable @keyup.enter="handleQuery" />
12
+      </el-form-item>
13
+      <el-form-item label="姓名" prop="personName">
14
+        <el-input v-model="queryParams.personName" placeholder="请输入姓名" clearable @keyup.enter="handleQuery" />
15
+      </el-form-item>
16
+      <el-form-item label="类别" prop="examCategory">
17
+        <el-input v-model="queryParams.examCategory" placeholder="请输入类别" clearable @keyup.enter="handleQuery" />
18
+      </el-form-item>
19
+      <el-form-item label="期数" prop="examPeriod">
20
+        <el-input v-model="queryParams.examPeriod" placeholder="请输入期数" clearable @keyup.enter="handleQuery" />
21
+      </el-form-item>
22
+      <el-form-item label="考试日期">
23
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
24
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
25
+      </el-form-item>
26
+      <el-form-item>
27
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
28
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
29
+      </el-form-item>
30
+    </el-form>
31
+    <el-row :gutter="10" class="mb8">
32
+      <el-col :span="1.5">
33
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:examScore:export']">导出</el-button>
34
+      </el-col>
35
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
36
+    </el-row>
37
+    <el-table v-loading="loading" :data="list">
38
+      <el-table-column label="考试日期" align="center" prop="examDate" width="110">
39
+        <template #default="{ row }">{{ parseTime(row.examDate, '{y}-{m}-{d}') }}</template>
40
+      </el-table-column>
41
+      <el-table-column label="部门名称" align="center" prop="deptName" />
42
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
43
+      <el-table-column label="小组" align="center" prop="groupName" />
44
+      <el-table-column label="姓名" align="center" prop="personName" />
45
+      <el-table-column label="类别" align="center" prop="examCategory" />
46
+      <el-table-column label="期数" align="center" prop="examPeriod" />
47
+      <el-table-column label="成绩" align="center" prop="score" />
48
+    </el-table>
49
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
50
+  </div>
51
+</template>
52
+<script setup>
53
+import { ref, reactive, onMounted } from 'vue'
54
+import { listExamScore, exportExamScore } from '@/api/ledger/index'
55
+import { parseTime } from '@/utils/ruoyi'
56
+defineOptions({ name: 'LedgerExamScore' })
57
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
58
+const dateRange = ref([]), queryRef = ref(null)
59
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '', groupName: '', personName: '', examCategory: '', examPeriod: '' })
60
+function getList() {
61
+  loading.value = true
62
+  const p = { ...queryParams }
63
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
64
+  listExamScore(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
65
+}
66
+function handleQuery() { queryParams.pageNum = 1; getList() }
67
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
68
+function handleExport() {
69
+  const p = { ...queryParams }
70
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
71
+  exportExamScore(p)
72
+}
73
+onMounted(getList)
74
+</script>

+ 448 - 0
src/views/ledger/import/index.vue

@@ -0,0 +1,448 @@
1
+<template>
2
+  <div class="ledger-import-page">
3
+    <el-card class="page-header-card">
4
+      <div class="page-title">台账一键导入</div>
5
+      <div class="page-desc">支持12类台账Excel批量上传,请按模板格式准备文件后上传</div>
6
+    </el-card>
7
+
8
+    <!-- 一键全量导入 -->
9
+    <el-card class="combined-import-card" shadow="never">
10
+      <div class="combined-inner">
11
+        <div class="combined-info">
12
+          <el-icon class="combined-icon"><Files /></el-icon>
13
+          <div>
14
+            <div class="combined-title">一键全量导入</div>
15
+            <div class="combined-desc">上传「旅检三部"三三"数字管理平台.xlsx」,系统将自动识别15个Sheet并分别导入对应台账表</div>
16
+          </div>
17
+        </div>
18
+        <el-upload
19
+          :action="''"
20
+          :auto-upload="false"
21
+          :show-file-list="false"
22
+          :on-change="handleCombinedChange"
23
+          accept=".xlsx,.xls"
24
+        >
25
+          <el-button type="primary" size="large" :loading="combinedLoading" class="combined-btn">
26
+            <el-icon><Upload /></el-icon> 选择文件并全量导入
27
+          </el-button>
28
+        </el-upload>
29
+      </div>
30
+      <div v-if="combinedResult" class="combined-result">
31
+        <div class="result-title">导入结果:</div>
32
+        <el-tag
33
+          v-for="(msg, sheet) in combinedResult" :key="sheet"
34
+          :type="msg.includes('失败') || msg.includes('错误') ? 'danger' : 'success'"
35
+          class="result-tag"
36
+        >
37
+          {{ sheet }}:{{ msg }}
38
+        </el-tag>
39
+      </div>
40
+    </el-card>
41
+
42
+    <el-divider content-position="left">或按类型单独导入</el-divider>
43
+
44
+    <div class="import-grid">
45
+      <div v-for="item in importItems" :key="item.key" class="import-card">
46
+        <el-card shadow="hover" :class="['ledger-card', item.status]">
47
+          <div class="card-header">
48
+            <el-icon class="card-icon"><component :is="item.icon" /></el-icon>
49
+            <div class="card-title">{{ item.title }}</div>
50
+          </div>
51
+          <div class="card-desc">{{ item.desc }}</div>
52
+          <div class="card-actions">
53
+            <el-upload
54
+              ref="uploadRefs"
55
+              :action="''"
56
+              :auto-upload="false"
57
+              :show-file-list="false"
58
+              :before-upload="(file) => beforeUpload(file, item)"
59
+              :on-change="(file) => handleFileChange(file, item)"
60
+              accept=".xlsx,.xls"
61
+            >
62
+              <el-button type="primary" size="small" :loading="item.loading">
63
+                <el-icon><Upload /></el-icon> 选择文件上传
64
+              </el-button>
65
+            </el-upload>
66
+            <el-button size="small" text @click="downloadTemplate(item)">
67
+              <el-icon><Download /></el-icon> 下载模板
68
+            </el-button>
69
+          </div>
70
+          <div v-if="item.lastResult" class="last-result" :class="item.lastResult.success ? 'success' : 'error'">
71
+            <el-icon><component :is="item.lastResult.success ? 'CircleCheck' : 'CircleClose'" /></el-icon>
72
+            {{ item.lastResult.msg }}
73
+          </div>
74
+        </el-card>
75
+      </div>
76
+    </div>
77
+  </div>
78
+</template>
79
+
80
+<script setup>
81
+import { ref, reactive } from 'vue'
82
+import { ElMessage } from 'element-plus'
83
+import { Upload, Download, Document, DocumentChecked, Warning, Trophy, UserFilled, Ticket, DataAnalysis, Histogram, Medal, Memo, Money, Calendar, Flag, Files } from '@element-plus/icons-vue'
84
+import { importCombinedLedger } from '@/api/ledger/index'
85
+import {
86
+  importSupervisionProblem,
87
+  importPatrolInspection,
88
+  importRealtimeInterception,
89
+  importServicePatrol,
90
+  importComplaint,
91
+  importSecurityTest,
92
+  importChannelPassRate,
93
+  importUnsafeEvent,
94
+  importSeizureStats,
95
+  importTerminalBonus,
96
+  importExamScore,
97
+  importRewardApproval
98
+} from '@/api/ledger/index'
99
+
100
+defineOptions({ name: 'LedgerImport' })
101
+
102
+// ── 一键全量导入 ──────────────────────────────────────
103
+const combinedLoading = ref(false)
104
+const combinedResult = ref(null)
105
+
106
+async function handleCombinedChange(uploadFile) {
107
+  const file = uploadFile.raw
108
+  if (!file) return
109
+  if (!beforeUpload(file)) return
110
+
111
+  combinedLoading.value = true
112
+  combinedResult.value = null
113
+
114
+  const formData = new FormData()
115
+  formData.append('file', file)
116
+
117
+  try {
118
+    const res = await importCombinedLedger(formData)
119
+    if (res.code === 200) {
120
+      combinedResult.value = res.data || {}
121
+      ElMessage.success('全量导入完成,请查看各Sheet结果')
122
+    } else {
123
+      ElMessage.error(res.msg || '全量导入失败')
124
+    }
125
+  } catch (e) {
126
+    ElMessage.error('全量导入失败,请查看后端日志')
127
+  } finally {
128
+    combinedLoading.value = false
129
+  }
130
+}
131
+
132
+// ── 单类型导入 ──────────────────────────────────────
133
+const importItems = reactive([
134
+  {
135
+    key: 'supervisionProblem',
136
+    title: '部门监察问题记录',
137
+    desc: '记录监察问题、扣加分及评分维度',
138
+    icon: 'Warning',
139
+    api: importSupervisionProblem,
140
+    loading: false,
141
+    lastResult: null
142
+  },
143
+  {
144
+    key: 'patrolInspection',
145
+    title: '队室三级质控巡查记录',
146
+    desc: '队室层级巡查问题记录',
147
+    icon: 'DocumentChecked',
148
+    api: importPatrolInspection,
149
+    loading: false,
150
+    lastResult: null
151
+  },
152
+  {
153
+    key: 'realtimeInterception',
154
+    title: '实时质控拦截记录',
155
+    desc: '实时拦截物品及旅客记录',
156
+    icon: 'Ticket',
157
+    api: importRealtimeInterception,
158
+    loading: false,
159
+    lastResult: null
160
+  },
161
+  {
162
+    key: 'servicePatrol',
163
+    title: '服务巡查记录',
164
+    desc: '服务质量巡查问题记录',
165
+    icon: 'UserFilled',
166
+    api: importServicePatrol,
167
+    loading: false,
168
+    lastResult: null
169
+  },
170
+  {
171
+    key: 'complaint',
172
+    title: '投诉情况记录',
173
+    desc: '旅客投诉及处理结果',
174
+    icon: 'Memo',
175
+    api: importComplaint,
176
+    loading: false,
177
+    lastResult: null
178
+  },
179
+  {
180
+    key: 'securityTest',
181
+    title: '安保测试记录(部门)',
182
+    desc: '安保测试项目及结果',
183
+    icon: 'Flag',
184
+    api: importSecurityTest,
185
+    loading: false,
186
+    lastResult: null
187
+  },
188
+  {
189
+    key: 'channelPassRate',
190
+    title: '通道过检率',
191
+    desc: '各通道过检人数及过检率',
192
+    icon: 'DataAnalysis',
193
+    api: importChannelPassRate,
194
+    loading: false,
195
+    lastResult: null
196
+  },
197
+  {
198
+    key: 'unsafeEvent',
199
+    title: '不安全事件',
200
+    desc: '安全事件记录及处理',
201
+    icon: 'Document',
202
+    api: importUnsafeEvent,
203
+    loading: false,
204
+    lastResult: null
205
+  },
206
+  {
207
+    key: 'seizureStats',
208
+    title: '2026查获违规品统计',
209
+    desc: '查获违规品统计数据',
210
+    icon: 'Histogram',
211
+    api: importSeizureStats,
212
+    loading: false,
213
+    lastResult: null
214
+  },
215
+  {
216
+    key: 'terminalBonus',
217
+    title: '航站楼加分',
218
+    desc: '航站楼相关加分记录',
219
+    icon: 'Trophy',
220
+    api: importTerminalBonus,
221
+    loading: false,
222
+    lastResult: null
223
+  },
224
+  {
225
+    key: 'examScore',
226
+    title: '成绩收集',
227
+    desc: '考试成绩汇总导入',
228
+    icon: 'Medal',
229
+    api: importExamScore,
230
+    loading: false,
231
+    lastResult: null
232
+  },
233
+  {
234
+    key: 'rewardApproval',
235
+    title: '小额奖励审批单',
236
+    desc: '小额奖励审批流程记录',
237
+    icon: 'Money',
238
+    api: importRewardApproval,
239
+    loading: false,
240
+    lastResult: null
241
+  }
242
+])
243
+
244
+function beforeUpload(file) {
245
+  const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
246
+    || file.type === 'application/vnd.ms-excel'
247
+    || file.name.endsWith('.xlsx')
248
+    || file.name.endsWith('.xls')
249
+  if (!isExcel) {
250
+    ElMessage.error('只支持上传 .xlsx / .xls 格式文件')
251
+    return false
252
+  }
253
+  if (file.size > 10 * 1024 * 1024) {
254
+    ElMessage.error('文件大小不能超过 10MB')
255
+    return false
256
+  }
257
+  return true
258
+}
259
+
260
+async function handleFileChange(uploadFile, item) {
261
+  const file = uploadFile.raw
262
+  if (!file) return
263
+  if (!beforeUpload(file)) return
264
+
265
+  item.loading = true
266
+  item.lastResult = null
267
+
268
+  const formData = new FormData()
269
+  formData.append('file', file)
270
+
271
+  try {
272
+    const res = await item.api(formData)
273
+    if (res.code === 200) {
274
+      item.lastResult = { success: true, msg: res.msg || '导入成功' }
275
+      ElMessage.success(item.title + ' - ' + (res.msg || '导入成功'))
276
+    } else {
277
+      item.lastResult = { success: false, msg: res.msg || '导入失败' }
278
+      ElMessage.error(item.title + ' - ' + (res.msg || '导入失败'))
279
+    }
280
+  } catch (e) {
281
+    item.lastResult = { success: false, msg: '网络异常,导入失败' }
282
+    ElMessage.error(item.title + ' - 网络异常')
283
+  } finally {
284
+    item.loading = false
285
+  }
286
+}
287
+
288
+function downloadTemplate(item) {
289
+  ElMessage.info('模板文件功能开发中,请联系管理员获取模板')
290
+}
291
+</script>
292
+
293
+<style lang="scss" scoped>
294
+.ledger-import-page {
295
+  padding: 20px;
296
+  background: #f5f7fa;
297
+  min-height: 100vh;
298
+}
299
+
300
+.page-header-card {
301
+  margin-bottom: 20px;
302
+  .page-title {
303
+    font-size: 20px;
304
+    font-weight: 600;
305
+    color: #303133;
306
+    margin-bottom: 6px;
307
+  }
308
+  .page-desc {
309
+    font-size: 13px;
310
+    color: #909399;
311
+  }
312
+}
313
+
314
+.combined-import-card {
315
+  margin-bottom: 16px;
316
+
317
+  .combined-inner {
318
+    display: flex;
319
+    align-items: center;
320
+    justify-content: space-between;
321
+    gap: 16px;
322
+    flex-wrap: wrap;
323
+  }
324
+
325
+  .combined-info {
326
+    display: flex;
327
+    align-items: flex-start;
328
+    gap: 12px;
329
+  }
330
+
331
+  .combined-icon {
332
+    font-size: 36px;
333
+    color: #409eff;
334
+    flex-shrink: 0;
335
+    margin-top: 2px;
336
+  }
337
+
338
+  .combined-title {
339
+    font-size: 17px;
340
+    font-weight: 600;
341
+    color: #303133;
342
+    margin-bottom: 4px;
343
+  }
344
+
345
+  .combined-desc {
346
+    font-size: 13px;
347
+    color: #606266;
348
+    max-width: 580px;
349
+  }
350
+
351
+  .combined-btn {
352
+    min-width: 180px;
353
+    height: 44px;
354
+    font-size: 15px;
355
+  }
356
+
357
+  .combined-result {
358
+    margin-top: 14px;
359
+    padding-top: 14px;
360
+    border-top: 1px dashed #dcdfe6;
361
+
362
+    .result-title {
363
+      font-size: 13px;
364
+      color: #606266;
365
+      margin-bottom: 8px;
366
+      font-weight: 500;
367
+    }
368
+
369
+    .result-tag {
370
+      margin-right: 8px;
371
+      margin-bottom: 6px;
372
+    }
373
+  }
374
+}
375
+
376
+.import-grid {
377
+  display: grid;
378
+  grid-template-columns: repeat(2, 1fr);
379
+  gap: 16px;
380
+}
381
+
382
+.ledger-card {
383
+  height: 100%;
384
+
385
+  :deep(.el-card__body) {
386
+    padding: 16px;
387
+  }
388
+
389
+  .card-header {
390
+    display: flex;
391
+    align-items: center;
392
+    gap: 8px;
393
+    margin-bottom: 8px;
394
+  }
395
+
396
+  .card-icon {
397
+    font-size: 20px;
398
+    color: #409eff;
399
+    flex-shrink: 0;
400
+  }
401
+
402
+  .card-title {
403
+    font-size: 15px;
404
+    font-weight: 600;
405
+    color: #303133;
406
+    line-height: 1.3;
407
+  }
408
+
409
+  .card-desc {
410
+    font-size: 12px;
411
+    color: #909399;
412
+    margin-bottom: 12px;
413
+    min-height: 32px;
414
+  }
415
+
416
+  .card-actions {
417
+    display: flex;
418
+    gap: 8px;
419
+    align-items: center;
420
+    flex-wrap: wrap;
421
+  }
422
+
423
+  .last-result {
424
+    margin-top: 10px;
425
+    font-size: 12px;
426
+    display: flex;
427
+    align-items: center;
428
+    gap: 4px;
429
+    padding: 4px 8px;
430
+    border-radius: 4px;
431
+
432
+    &.success {
433
+      background: #f0f9eb;
434
+      color: #67c23a;
435
+    }
436
+    &.error {
437
+      background: #fef0f0;
438
+      color: #f56c6c;
439
+    }
440
+  }
441
+}
442
+
443
+@media (max-width: 768px) {
444
+  .import-grid {
445
+    grid-template-columns: 1fr;
446
+  }
447
+}
448
+</style>

+ 140 - 0
src/views/ledger/leaveSpecial/index.vue

@@ -0,0 +1,140 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item>
11
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
12
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
13
+      </el-form-item>
14
+    </el-form>
15
+
16
+    <el-row :gutter="10" class="mb8">
17
+      <el-col :span="1.5">
18
+        <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['ledger:leaveSpecial:add']">新增</el-button>
19
+      </el-col>
20
+      <el-col :span="1.5">
21
+        <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['ledger:leaveSpecial:edit']">修改</el-button>
22
+      </el-col>
23
+      <el-col :span="1.5">
24
+        <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['ledger:leaveSpecial:remove']">删除</el-button>
25
+      </el-col>
26
+      <el-col :span="1.5">
27
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:leaveSpecial:export']">导出</el-button>
28
+      </el-col>
29
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
30
+    </el-row>
31
+
32
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange">
33
+      <el-table-column type="selection" width="55" align="center" />
34
+      <el-table-column label="部门名称" align="center" prop="deptName" />
35
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
36
+      <el-table-column label="姓名" align="center" prop="personName" />
37
+      <el-table-column label="假期类型" align="center" prop="leaveType" />
38
+      <el-table-column label="开始日期" align="center" prop="startDate" width="110">
39
+        <template #default="{ row }">{{ parseTime(row.startDate, '{y}-{m}-{d}') }}</template>
40
+      </el-table-column>
41
+      <el-table-column label="结束日期" align="center" prop="endDate" width="110">
42
+        <template #default="{ row }">{{ parseTime(row.endDate, '{y}-{m}-{d}') }}</template>
43
+      </el-table-column>
44
+      <el-table-column label="天数" align="center" prop="days" />
45
+      <el-table-column label="扣分" align="center" prop="deductScore">
46
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{ row.deductScore }}</span></template>
47
+      </el-table-column>
48
+      <el-table-column label="操作" align="center" width="120">
49
+        <template #default="{ row }">
50
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['ledger:leaveSpecial:edit']">修改</el-button>
51
+          <el-button link type="primary" icon="Delete" @click="handleDelete(row)" v-hasPermi="['ledger:leaveSpecial:remove']">删除</el-button>
52
+        </template>
53
+      </el-table-column>
54
+    </el-table>
55
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
56
+
57
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" append-to-body>
58
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
59
+        <el-form-item label="部门名称" prop="deptName">
60
+          <el-input v-model="form.deptName" placeholder="请输入部门名称" />
61
+        </el-form-item>
62
+        <el-form-item label="队室/班组" prop="teamName">
63
+          <el-input v-model="form.teamName" placeholder="请输入队室/班组" />
64
+        </el-form-item>
65
+        <el-form-item label="姓名" prop="personName">
66
+          <el-input v-model="form.personName" placeholder="请输入姓名" />
67
+        </el-form-item>
68
+        <el-form-item label="假期类型" prop="leaveType">
69
+          <el-input v-model="form.leaveType" placeholder="请输入假期类型" />
70
+        </el-form-item>
71
+        <el-form-item label="开始日期" prop="startDate">
72
+          <el-date-picker v-model="form.startDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择开始日期" style="width:100%" />
73
+        </el-form-item>
74
+        <el-form-item label="结束日期" prop="endDate">
75
+          <el-date-picker v-model="form.endDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择结束日期" style="width:100%" />
76
+        </el-form-item>
77
+        <el-form-item label="天数" prop="days">
78
+          <el-input-number v-model="form.days" :precision="1" :min="0" style="width:100%" />
79
+        </el-form-item>
80
+        <el-form-item label="扣分" prop="deductScore">
81
+          <el-input-number v-model="form.deductScore" :precision="2" :min="0" style="width:100%" />
82
+        </el-form-item>
83
+        <el-form-item label="备注" prop="remark">
84
+          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
85
+        </el-form-item>
86
+      </el-form>
87
+      <template #footer>
88
+        <el-button @click="dialogVisible = false">取消</el-button>
89
+        <el-button type="primary" @click="submitForm">确定</el-button>
90
+      </template>
91
+    </el-dialog>
92
+  </div>
93
+</template>
94
+
95
+<script setup>
96
+import { ref, reactive, onMounted } from 'vue'
97
+import { ElMessage, ElMessageBox } from 'element-plus'
98
+import { listLeaveSpecial, addLeaveSpecial, updateLeaveSpecial, delLeaveSpecial, exportLeaveSpecial } from '@/api/ledger/index'
99
+import { parseTime } from '@/utils/ruoyi'
100
+
101
+defineOptions({ name: 'LedgerLeaveSpecial' })
102
+
103
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
104
+const queryRef = ref(null), formRef = ref(null)
105
+const dialogVisible = ref(false), dialogTitle = ref('')
106
+const single = ref(true), multiple = ref(true), ids = ref([])
107
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
108
+const form = reactive({ id: null, deptName: '', teamName: '', personName: '', leaveType: '', startDate: '', endDate: '', days: 0, deductScore: 0, remark: '' })
109
+const rules = { personName: [{ required: true, message: '请输入姓名', trigger: 'blur' }], leaveType: [{ required: true, message: '请输入假期类型', trigger: 'blur' }] }
110
+
111
+function getList() {
112
+  loading.value = true
113
+  listLeaveSpecial({ ...queryParams }).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
114
+}
115
+function handleQuery() { queryParams.pageNum = 1; getList() }
116
+function resetQuery() { queryRef.value?.resetFields(); handleQuery() }
117
+function handleSelectionChange(sel) { ids.value = sel.map(s => s.id); single.value = sel.length !== 1; multiple.value = !sel.length }
118
+function resetForm() { Object.assign(form, { id: null, deptName: '', teamName: '', personName: '', leaveType: '', startDate: '', endDate: '', days: 0, deductScore: 0, remark: '' }) }
119
+function handleAdd() { resetForm(); dialogTitle.value = '新增请休假记录'; dialogVisible.value = true }
120
+function handleUpdate(row) {
121
+  resetForm()
122
+  const record = row || list.value.find(r => r.id === ids.value[0])
123
+  if (record) Object.assign(form, record)
124
+  dialogTitle.value = '修改请休假记录'; dialogVisible.value = true
125
+}
126
+async function submitForm() {
127
+  await formRef.value.validate()
128
+  if (form.id) { await updateLeaveSpecial(form); ElMessage.success('修改成功') }
129
+  else { await addLeaveSpecial(form); ElMessage.success('新增成功') }
130
+  dialogVisible.value = false; getList()
131
+}
132
+function handleDelete(row) {
133
+  const delIds = row?.id ? [row.id] : ids.value
134
+  ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' }).then(() => {
135
+    delLeaveSpecial(delIds.join(',')).then(() => { ElMessage.success('删除成功'); getList() })
136
+  })
137
+}
138
+function handleExport() { exportLeaveSpecial({ ...queryParams }) }
139
+onMounted(getList)
140
+</script>

+ 92 - 0
src/views/ledger/patrolInspection/index.vue

@@ -0,0 +1,92 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+
11
+      <el-form-item label="记录日期">
12
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
13
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
14
+      </el-form-item>
15
+      <el-form-item>
16
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
17
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
18
+      </el-form-item>
19
+    </el-form>
20
+
21
+    <el-row :gutter="10" class="mb8">
22
+      <el-col :span="1.5">
23
+        <el-button type="warning" plain icon="Download" @click="handleExport"
24
+          v-hasPermi="['ledger:patrolInspection:export']">导出</el-button>
25
+      </el-col>
26
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
27
+    </el-row>
28
+
29
+    <el-table v-loading="loading" :data="list">
30
+      <el-table-column label="巡查日期" align="center" prop="recordDate" width="110">
31
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
32
+      </el-table-column>
33
+      <el-table-column label="部门名称" align="center" prop="deptName" />
34
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
35
+      <el-table-column label="小组" align="center" prop="groupName" />
36
+      <el-table-column label="巡查人" align="center" prop="inspectorName" />
37
+      <el-table-column label="被查人" align="center" prop="inspectedName" />
38
+      <el-table-column label="巡查类型" align="center" prop="patrolType" />
39
+      <el-table-column label="问题描述" align="center" prop="problemDesc" show-overflow-tooltip />
40
+      <el-table-column label="扣分" align="center" prop="deductScore">
41
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{row.deductScore}}</span></template>
42
+      </el-table-column>
43
+      <el-table-column label="评分维度" align="center" prop="scoreDimension" />
44
+    </el-table>
45
+
46
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
47
+      v-model:limit="queryParams.pageSize" @pagination="getList" />
48
+  </div>
49
+</template>
50
+
51
+<script setup>
52
+import { ref, reactive, onMounted } from 'vue'
53
+import { listPatrolInspection, exportPatrolInspection } from '@/api/ledger/index'
54
+import { parseTime } from '@/utils/ruoyi'
55
+
56
+defineOptions({ name: 'PatrolInspectionPage' })
57
+
58
+const loading = ref(false)
59
+const list = ref([])
60
+const total = ref(0)
61
+const showSearch = ref(true)
62
+const dateRange = ref([])
63
+const queryRef = ref(null)
64
+
65
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
66
+
67
+function getList() {
68
+  loading.value = true
69
+  const params = { ...queryParams }
70
+  if (dateRange.value && dateRange.value.length === 2) {
71
+    params['params[beginTime]'] = dateRange.value[0]
72
+    params['params[endTime]'] = dateRange.value[1]
73
+  }
74
+  listPatrolInspection(params).then(res => {
75
+    list.value = res.rows
76
+    total.value = res.total
77
+  }).finally(() => { loading.value = false })
78
+}
79
+
80
+function handleQuery() { queryParams.pageNum = 1; getList() }
81
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
82
+function handleExport() {
83
+  const params = { ...queryParams }
84
+  if (dateRange.value && dateRange.value.length === 2) {
85
+    params['params[beginTime]'] = dateRange.value[0]
86
+    params['params[endTime]'] = dateRange.value[1]
87
+  }
88
+  exportPatrolInspection(params)
89
+}
90
+
91
+onMounted(getList)
92
+</script>

+ 93 - 0
src/views/ledger/realtimeInterception/index.vue

@@ -0,0 +1,93 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+
11
+      <el-form-item label="记录日期">
12
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
13
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
14
+      </el-form-item>
15
+      <el-form-item>
16
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
17
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
18
+      </el-form-item>
19
+    </el-form>
20
+
21
+    <el-row :gutter="10" class="mb8">
22
+      <el-col :span="1.5">
23
+        <el-button type="warning" plain icon="Download" @click="handleExport"
24
+          v-hasPermi="['ledger:realtimeInterception:export']">导出</el-button>
25
+      </el-col>
26
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
27
+    </el-row>
28
+
29
+    <el-table v-loading="loading" :data="list">
30
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
31
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
32
+      </el-table-column>
33
+      <el-table-column label="部门名称" align="center" prop="deptName" />
34
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
35
+      <el-table-column label="查获人" align="center" prop="inspectorName" />
36
+      <el-table-column label="通道号" align="center" prop="channelNo" />
37
+      <el-table-column label="旅客姓名" align="center" prop="passengerName" />
38
+      <el-table-column label="航班号" align="center" prop="flightNo" />
39
+      <el-table-column label="物品类别" align="center" prop="itemCategory" />
40
+      <el-table-column label="物品名称" align="center" prop="itemName" />
41
+      <el-table-column label="处置方式" align="center" prop="handlingMethod" />
42
+      <el-table-column label="加分" align="center" prop="addScore">
43
+        <template #default="{ row }"><span v-if="row.addScore" style="color:#67c23a">+{{row.addScore}}</span></template>
44
+      </el-table-column>
45
+    </el-table>
46
+
47
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
48
+      v-model:limit="queryParams.pageSize" @pagination="getList" />
49
+  </div>
50
+</template>
51
+
52
+<script setup>
53
+import { ref, reactive, onMounted } from 'vue'
54
+import { listRealtimeInterception, exportRealtimeInterception } from '@/api/ledger/index'
55
+import { parseTime } from '@/utils/ruoyi'
56
+
57
+defineOptions({ name: 'RealtimeInterceptionPage' })
58
+
59
+const loading = ref(false)
60
+const list = ref([])
61
+const total = ref(0)
62
+const showSearch = ref(true)
63
+const dateRange = ref([])
64
+const queryRef = ref(null)
65
+
66
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
67
+
68
+function getList() {
69
+  loading.value = true
70
+  const params = { ...queryParams }
71
+  if (dateRange.value && dateRange.value.length === 2) {
72
+    params['params[beginTime]'] = dateRange.value[0]
73
+    params['params[endTime]'] = dateRange.value[1]
74
+  }
75
+  listRealtimeInterception(params).then(res => {
76
+    list.value = res.rows
77
+    total.value = res.total
78
+  }).finally(() => { loading.value = false })
79
+}
80
+
81
+function handleQuery() { queryParams.pageNum = 1; getList() }
82
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
83
+function handleExport() {
84
+  const params = { ...queryParams }
85
+  if (dateRange.value && dateRange.value.length === 2) {
86
+    params['params[beginTime]'] = dateRange.value[0]
87
+    params['params[endTime]'] = dateRange.value[1]
88
+  }
89
+  exportRealtimeInterception(params)
90
+}
91
+
92
+onMounted(getList)
93
+</script>

+ 61 - 0
src/views/ledger/rewardApproval/index.vue

@@ -0,0 +1,61 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="审批日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+    <el-row :gutter="10" class="mb8">
20
+      <el-col :span="1.5">
21
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:rewardApproval:export']">导出</el-button>
22
+      </el-col>
23
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
24
+    </el-row>
25
+    <el-table v-loading="loading" :data="list">
26
+      <el-table-column label="审批日期" align="center" prop="approveDate" width="110">
27
+        <template #default="{ row }">{{ parseTime(row.approveDate, '{y}-{m}-{d}') }}</template>
28
+      </el-table-column>
29
+      <el-table-column label="部门名称" align="center" prop="deptName" />
30
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
31
+      <el-table-column label="姓名" align="center" prop="personName" />
32
+      <el-table-column label="奖励类型" align="center" prop="rewardType" />
33
+      <el-table-column label="奖励金额" align="center" prop="rewardAmount" />
34
+      <el-table-column label="审批状态" align="center" prop="approvalStatus" />
35
+    </el-table>
36
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
37
+  </div>
38
+</template>
39
+<script setup>
40
+import { ref, reactive, onMounted } from 'vue'
41
+import { listRewardApproval, exportRewardApproval } from '@/api/ledger/index'
42
+import { parseTime } from '@/utils/ruoyi'
43
+defineOptions({ name: 'LedgerRewardApproval' })
44
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
45
+const dateRange = ref([]), queryRef = ref(null)
46
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
47
+function getList() {
48
+  loading.value = true
49
+  const p = { ...queryParams }
50
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
51
+  listRewardApproval(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
52
+}
53
+function handleQuery() { queryParams.pageNum = 1; getList() }
54
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
55
+function handleExport() {
56
+  const p = { ...queryParams }
57
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
58
+  exportRewardApproval(p)
59
+}
60
+onMounted(getList)
61
+</script>

+ 173 - 0
src/views/ledger/rewardPenalty/index.vue

@@ -0,0 +1,173 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="记录日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+
20
+    <el-row :gutter="10" class="mb8">
21
+      <el-col :span="1.5">
22
+        <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['ledger:rewardPenalty:add']">新增</el-button>
23
+      </el-col>
24
+      <el-col :span="1.5">
25
+        <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['ledger:rewardPenalty:edit']">修改</el-button>
26
+      </el-col>
27
+      <el-col :span="1.5">
28
+        <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['ledger:rewardPenalty:remove']">删除</el-button>
29
+      </el-col>
30
+      <el-col :span="1.5">
31
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:rewardPenalty:export']">导出</el-button>
32
+      </el-col>
33
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
34
+    </el-row>
35
+
36
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange">
37
+      <el-table-column type="selection" width="55" align="center" />
38
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
39
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
40
+      </el-table-column>
41
+      <el-table-column label="部门名称" align="center" prop="deptName" />
42
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
43
+      <el-table-column label="姓名" align="center" prop="personName" />
44
+      <el-table-column label="类型" align="center" prop="type">
45
+        <template #default="{ row }">
46
+          <el-tag :type="row.type === '1' ? 'success' : 'danger'">{{ row.type === '1' ? '奖励' : '惩处' }}</el-tag>
47
+        </template>
48
+      </el-table-column>
49
+      <el-table-column label="事件描述" align="center" prop="eventDesc" show-overflow-tooltip />
50
+      <el-table-column label="分值变动" align="center" prop="scoreChange">
51
+        <template #default="{ row }">
52
+          <span :style="{ color: row.scoreChange >= 0 ? '#67c23a' : '#f56c6c' }">
53
+            {{ row.scoreChange >= 0 ? '+' : '' }}{{ row.scoreChange }}
54
+          </span>
55
+        </template>
56
+      </el-table-column>
57
+      <el-table-column label="操作" align="center" width="120">
58
+        <template #default="{ row }">
59
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['ledger:rewardPenalty:edit']">修改</el-button>
60
+          <el-button link type="primary" icon="Delete" @click="handleDelete(row)" v-hasPermi="['ledger:rewardPenalty:remove']">删除</el-button>
61
+        </template>
62
+      </el-table-column>
63
+    </el-table>
64
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
65
+
66
+    <!-- 新增/修改对话框 -->
67
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" append-to-body>
68
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
69
+        <el-form-item label="记录日期" prop="recordDate">
70
+          <el-date-picker v-model="form.recordDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择记录日期" style="width:100%" />
71
+        </el-form-item>
72
+        <el-form-item label="部门名称" prop="deptName">
73
+          <el-input v-model="form.deptName" placeholder="请输入部门名称" />
74
+        </el-form-item>
75
+        <el-form-item label="队室/班组" prop="teamName">
76
+          <el-input v-model="form.teamName" placeholder="请输入队室/班组" />
77
+        </el-form-item>
78
+        <el-form-item label="姓名" prop="personName">
79
+          <el-input v-model="form.personName" placeholder="请输入姓名" />
80
+        </el-form-item>
81
+        <el-form-item label="类型" prop="type">
82
+          <el-radio-group v-model="form.type">
83
+            <el-radio value="1">奖励</el-radio>
84
+            <el-radio value="2">惩处</el-radio>
85
+          </el-radio-group>
86
+        </el-form-item>
87
+        <el-form-item label="事件描述" prop="eventDesc">
88
+          <el-input v-model="form.eventDesc" type="textarea" :rows="3" placeholder="请输入事件描述" />
89
+        </el-form-item>
90
+        <el-form-item label="分值变动" prop="scoreChange">
91
+          <el-input-number v-model="form.scoreChange" :precision="2" placeholder="正数为加分,负数为扣分" style="width:100%" />
92
+        </el-form-item>
93
+        <el-form-item label="评分维度" prop="scoreDimension">
94
+          <el-input v-model="form.scoreDimension" placeholder="请输入评分维度" />
95
+        </el-form-item>
96
+        <el-form-item label="备注" prop="remark">
97
+          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
98
+        </el-form-item>
99
+      </el-form>
100
+      <template #footer>
101
+        <el-button @click="dialogVisible = false">取消</el-button>
102
+        <el-button type="primary" @click="submitForm">确定</el-button>
103
+      </template>
104
+    </el-dialog>
105
+  </div>
106
+</template>
107
+
108
+<script setup>
109
+import { ref, reactive, onMounted } from 'vue'
110
+import { ElMessage, ElMessageBox } from 'element-plus'
111
+import { listRewardPenalty, addRewardPenalty, updateRewardPenalty, delRewardPenalty, exportRewardPenalty } from '@/api/ledger/index'
112
+import { parseTime } from '@/utils/ruoyi'
113
+
114
+defineOptions({ name: 'LedgerRewardPenalty' })
115
+
116
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
117
+const dateRange = ref([]), queryRef = ref(null), formRef = ref(null)
118
+const dialogVisible = ref(false), dialogTitle = ref('')
119
+const single = ref(true), multiple = ref(true)
120
+const ids = ref([])
121
+
122
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
123
+const form = reactive({ id: null, recordDate: '', deptName: '', teamName: '', personName: '', type: '1', eventDesc: '', scoreChange: 0, scoreDimension: '', remark: '' })
124
+const rules = { recordDate: [{ required: true, message: '请选择记录日期', trigger: 'blur' }], personName: [{ required: true, message: '请输入姓名', trigger: 'blur' }], type: [{ required: true, message: '请选择类型', trigger: 'change' }] }
125
+
126
+function getList() {
127
+  loading.value = true
128
+  const p = { ...queryParams }
129
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
130
+  listRewardPenalty(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
131
+}
132
+function handleQuery() { queryParams.pageNum = 1; getList() }
133
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
134
+function handleSelectionChange(selection) { ids.value = selection.map(s => s.id); single.value = selection.length !== 1; multiple.value = !selection.length }
135
+
136
+function resetForm() { Object.assign(form, { id: null, recordDate: '', deptName: '', teamName: '', personName: '', type: '1', eventDesc: '', scoreChange: 0, scoreDimension: '', remark: '' }) }
137
+
138
+function handleAdd() { resetForm(); dialogTitle.value = '新增奖惩记录'; dialogVisible.value = true }
139
+function handleUpdate(row) {
140
+  resetForm()
141
+  const record = row || list.value.find(r => r.id === ids.value[0])
142
+  if (record) Object.assign(form, record)
143
+  dialogTitle.value = '修改奖惩记录'; dialogVisible.value = true
144
+}
145
+
146
+async function submitForm() {
147
+  await formRef.value.validate()
148
+  if (form.id) {
149
+    await updateRewardPenalty(form)
150
+    ElMessage.success('修改成功')
151
+  } else {
152
+    await addRewardPenalty(form)
153
+    ElMessage.success('新增成功')
154
+  }
155
+  dialogVisible.value = false
156
+  getList()
157
+}
158
+
159
+function handleDelete(row) {
160
+  const delIds = row?.id ? [row.id] : ids.value
161
+  ElMessageBox.confirm('确认删除选中的记录?', '提示', { type: 'warning' }).then(() => {
162
+    delRewardPenalty(delIds.join(',')).then(() => { ElMessage.success('删除成功'); getList() })
163
+  })
164
+}
165
+
166
+function handleExport() {
167
+  const p = { ...queryParams }
168
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
169
+  exportRewardPenalty(p)
170
+}
171
+
172
+onMounted(getList)
173
+</script>

+ 65 - 0
src/views/ledger/securityTest/index.vue

@@ -0,0 +1,65 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="记录日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+    <el-row :gutter="10" class="mb8">
20
+      <el-col :span="1.5">
21
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:securityTest:export']">导出</el-button>
22
+      </el-col>
23
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
24
+    </el-row>
25
+    <el-table v-loading="loading" :data="list">
26
+      <el-table-column label="测试日期" align="center" prop="recordDate" width="110">
27
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
28
+      </el-table-column>
29
+      <el-table-column label="部门名称" align="center" prop="deptName" />
30
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
31
+      <el-table-column label="测试人" align="center" prop="testerName" />
32
+      <el-table-column label="被测人" align="center" prop="testedName" />
33
+      <el-table-column label="测试类型" align="center" prop="testType" />
34
+      <el-table-column label="测试项目" align="center" prop="testItem" />
35
+      <el-table-column label="测试结果" align="center" prop="testResult" />
36
+      <el-table-column label="扣分" align="center" prop="deductScore">
37
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{ row.deductScore }}</span></template>
38
+      </el-table-column>
39
+    </el-table>
40
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
41
+  </div>
42
+</template>
43
+<script setup>
44
+import { ref, reactive, onMounted } from 'vue'
45
+import { listSecurityTest, exportSecurityTest } from '@/api/ledger/index'
46
+import { parseTime } from '@/utils/ruoyi'
47
+defineOptions({ name: 'LedgerSecurityTest' })
48
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
49
+const dateRange = ref([]), queryRef = ref(null)
50
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
51
+function getList() {
52
+  loading.value = true
53
+  const p = { ...queryParams }
54
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
55
+  listSecurityTest(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
56
+}
57
+function handleQuery() { queryParams.pageNum = 1; getList() }
58
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
59
+function handleExport() {
60
+  const p = { ...queryParams }
61
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
62
+  exportSecurityTest(p)
63
+}
64
+onMounted(getList)
65
+</script>

+ 66 - 0
src/views/ledger/seizureStats/index.vue

@@ -0,0 +1,66 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="查获日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+    <el-row :gutter="10" class="mb8">
20
+      <el-col :span="1.5">
21
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:seizureStats:export']">导出</el-button>
22
+      </el-col>
23
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
24
+    </el-row>
25
+    <el-table v-loading="loading" :data="list">
26
+      <el-table-column label="查获日期" align="center" prop="recordDate" width="110">
27
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
28
+      </el-table-column>
29
+      <el-table-column label="部门名称" align="center" prop="deptName" />
30
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
31
+      <el-table-column label="查获人" align="center" prop="inspectorName" />
32
+      <el-table-column label="通道号" align="center" prop="channelNo" />
33
+      <el-table-column label="违规品类别" align="center" prop="itemCategory" />
34
+      <el-table-column label="违规品名称" align="center" prop="itemName" />
35
+      <el-table-column label="藏匿部位" align="center" prop="concealmentPart" />
36
+      <el-table-column label="加分" align="center" prop="addScore">
37
+        <template #default="{ row }"><span v-if="row.addScore" style="color:#67c23a">+{{ row.addScore }}</span></template>
38
+      </el-table-column>
39
+      <el-table-column label="叠加分" align="center" prop="stackedScore" />
40
+    </el-table>
41
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
42
+  </div>
43
+</template>
44
+<script setup>
45
+import { ref, reactive, onMounted } from 'vue'
46
+import { listSeizureStats, exportSeizureStats } from '@/api/ledger/index'
47
+import { parseTime } from '@/utils/ruoyi'
48
+defineOptions({ name: 'LedgerSeizureStats' })
49
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
50
+const dateRange = ref([]), queryRef = ref(null)
51
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
52
+function getList() {
53
+  loading.value = true
54
+  const p = { ...queryParams }
55
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
56
+  listSeizureStats(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
57
+}
58
+function handleQuery() { queryParams.pageNum = 1; getList() }
59
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
60
+function handleExport() {
61
+  const p = { ...queryParams }
62
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
63
+  exportSeizureStats(p)
64
+}
65
+onMounted(getList)
66
+</script>

+ 90 - 0
src/views/ledger/servicePatrol/index.vue

@@ -0,0 +1,90 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+
11
+      <el-form-item label="记录日期">
12
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
13
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
14
+      </el-form-item>
15
+      <el-form-item>
16
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
17
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
18
+      </el-form-item>
19
+    </el-form>
20
+
21
+    <el-row :gutter="10" class="mb8">
22
+      <el-col :span="1.5">
23
+        <el-button type="warning" plain icon="Download" @click="handleExport"
24
+          v-hasPermi="['ledger:servicePatrol:export']">导出</el-button>
25
+      </el-col>
26
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
27
+    </el-row>
28
+
29
+    <el-table v-loading="loading" :data="list">
30
+      <el-table-column label="巡查日期" align="center" prop="recordDate" width="110">
31
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
32
+      </el-table-column>
33
+      <el-table-column label="部门名称" align="center" prop="deptName" />
34
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
35
+      <el-table-column label="巡查人" align="center" prop="inspectorName" />
36
+      <el-table-column label="被查人" align="center" prop="inspectedName" />
37
+      <el-table-column label="服务类型" align="center" prop="serviceType" />
38
+      <el-table-column label="问题描述" align="center" prop="problemDesc" show-overflow-tooltip />
39
+      <el-table-column label="扣分" align="center" prop="deductScore">
40
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{row.deductScore}}</span></template>
41
+      </el-table-column>
42
+    </el-table>
43
+
44
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
45
+      v-model:limit="queryParams.pageSize" @pagination="getList" />
46
+  </div>
47
+</template>
48
+
49
+<script setup>
50
+import { ref, reactive, onMounted } from 'vue'
51
+import { listServicePatrol, exportServicePatrol } from '@/api/ledger/index'
52
+import { parseTime } from '@/utils/ruoyi'
53
+
54
+defineOptions({ name: 'ServicePatrolPage' })
55
+
56
+const loading = ref(false)
57
+const list = ref([])
58
+const total = ref(0)
59
+const showSearch = ref(true)
60
+const dateRange = ref([])
61
+const queryRef = ref(null)
62
+
63
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
64
+
65
+function getList() {
66
+  loading.value = true
67
+  const params = { ...queryParams }
68
+  if (dateRange.value && dateRange.value.length === 2) {
69
+    params['params[beginTime]'] = dateRange.value[0]
70
+    params['params[endTime]'] = dateRange.value[1]
71
+  }
72
+  listServicePatrol(params).then(res => {
73
+    list.value = res.rows
74
+    total.value = res.total
75
+  }).finally(() => { loading.value = false })
76
+}
77
+
78
+function handleQuery() { queryParams.pageNum = 1; getList() }
79
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
80
+function handleExport() {
81
+  const params = { ...queryParams }
82
+  if (dateRange.value && dateRange.value.length === 2) {
83
+    params['params[beginTime]'] = dateRange.value[0]
84
+    params['params[endTime]'] = dateRange.value[1]
85
+  }
86
+  exportServicePatrol(params)
87
+}
88
+
89
+onMounted(getList)
90
+</script>

+ 127 - 0
src/views/ledger/supervisionProblem/index.vue

@@ -0,0 +1,127 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="巡查人" prop="inspectorName">
11
+        <el-input v-model="queryParams.inspectorName" placeholder="请输入巡查人" clearable @keyup.enter="handleQuery" />
12
+      </el-form-item>
13
+      <el-form-item label="被查人" prop="inspectedName">
14
+        <el-input v-model="queryParams.inspectedName" placeholder="请输入被查人" clearable @keyup.enter="handleQuery" />
15
+      </el-form-item>
16
+      <el-form-item label="记录日期">
17
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
18
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
19
+      </el-form-item>
20
+      <el-form-item>
21
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
22
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
23
+      </el-form-item>
24
+    </el-form>
25
+
26
+    <el-row :gutter="10" class="mb8">
27
+      <el-col :span="1.5">
28
+        <el-button type="warning" plain icon="Download" @click="handleExport"
29
+          v-hasPermi="['ledger:supervisionProblem:export']">导出</el-button>
30
+      </el-col>
31
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
32
+    </el-row>
33
+
34
+    <el-table v-loading="loading" :data="list">
35
+      <el-table-column label="记录日期" align="center" prop="recordDate" width="110">
36
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
37
+      </el-table-column>
38
+      <el-table-column label="部门名称" align="center" prop="deptName" />
39
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
40
+      <el-table-column label="小组" align="center" prop="groupName" />
41
+      <el-table-column label="巡查人" align="center" prop="inspectorName" />
42
+      <el-table-column label="被查人" align="center" prop="inspectedName" />
43
+      <el-table-column label="问题类型" align="center" prop="problemType" />
44
+      <el-table-column label="问题描述" align="center" prop="problemDesc" show-overflow-tooltip />
45
+      <el-table-column label="问题地点" align="center" prop="location" />
46
+      <el-table-column label="扣分" align="center" prop="deductScore">
47
+        <template #default="{ row }">
48
+          <span v-if="row.deductScore" style="color: #f56c6c;">-{{ row.deductScore }}</span>
49
+        </template>
50
+      </el-table-column>
51
+      <el-table-column label="加分" align="center" prop="addScore">
52
+        <template #default="{ row }">
53
+          <span v-if="row.addScore" style="color: #67c23a;">+{{ row.addScore }}</span>
54
+        </template>
55
+      </el-table-column>
56
+      <el-table-column label="评分维度" align="center" prop="scoreDimension" />
57
+      <el-table-column label="附件" align="center" prop="evidenceFile">
58
+        <template #default="{ row }">
59
+          <el-link v-if="row.evidenceFile" :href="row.evidenceFile" target="_blank" type="primary">查看</el-link>
60
+          <span v-else>-</span>
61
+        </template>
62
+      </el-table-column>
63
+    </el-table>
64
+
65
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
66
+      v-model:limit="queryParams.pageSize" @pagination="getList" />
67
+  </div>
68
+</template>
69
+
70
+<script setup>
71
+import { ref, reactive, onMounted } from 'vue'
72
+import { listSupervisionProblem, exportSupervisionProblem } from '@/api/ledger/index'
73
+import { parseTime } from '@/utils/ruoyi'
74
+
75
+defineOptions({ name: 'LedgerSupervisionProblem' })
76
+
77
+const loading = ref(false)
78
+const list = ref([])
79
+const total = ref(0)
80
+const showSearch = ref(true)
81
+const dateRange = ref([])
82
+const queryRef = ref(null)
83
+
84
+const queryParams = reactive({
85
+  pageNum: 1,
86
+  pageSize: 10,
87
+  deptName: '',
88
+  teamName: '',
89
+  inspectorName: '',
90
+  inspectedName: ''
91
+})
92
+
93
+function getList() {
94
+  loading.value = true
95
+  const params = { ...queryParams }
96
+  if (dateRange.value && dateRange.value.length === 2) {
97
+    params['params[beginTime]'] = dateRange.value[0]
98
+    params['params[endTime]'] = dateRange.value[1]
99
+  }
100
+  listSupervisionProblem(params).then(res => {
101
+    list.value = res.rows
102
+    total.value = res.total
103
+  }).finally(() => { loading.value = false })
104
+}
105
+
106
+function handleQuery() {
107
+  queryParams.pageNum = 1
108
+  getList()
109
+}
110
+
111
+function resetQuery() {
112
+  dateRange.value = []
113
+  queryRef.value?.resetFields()
114
+  handleQuery()
115
+}
116
+
117
+function handleExport() {
118
+  const params = { ...queryParams }
119
+  if (dateRange.value && dateRange.value.length === 2) {
120
+    params['params[beginTime]'] = dateRange.value[0]
121
+    params['params[endTime]'] = dateRange.value[1]
122
+  }
123
+  exportSupervisionProblem(params)
124
+}
125
+
126
+onMounted(getList)
127
+</script>

+ 71 - 0
src/views/ledger/terminalBonus/index.vue

@@ -0,0 +1,71 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="姓名" prop="personName">
11
+        <el-input v-model="queryParams.personName" placeholder="请输入姓名" clearable @keyup.enter="handleQuery" />
12
+      </el-form-item>
13
+      <el-form-item label="审核日期">
14
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
15
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
16
+      </el-form-item>
17
+      <el-form-item>
18
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
19
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
20
+      </el-form-item>
21
+    </el-form>
22
+    <el-row :gutter="10" class="mb8">
23
+      <el-col :span="1.5">
24
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:terminalBonus:export']">导出</el-button>
25
+      </el-col>
26
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
27
+    </el-row>
28
+    <el-table v-loading="loading" :data="list">
29
+      <el-table-column label="审核日期" align="center" prop="approveDate" width="110">
30
+        <template #default="{ row }">{{ parseTime(row.approveDate, '{y}-{m}-{d}') }}</template>
31
+      </el-table-column>
32
+      <el-table-column label="部门名称" align="center" prop="deptName" />
33
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
34
+      <el-table-column label="姓名" align="center" prop="personName" />
35
+      <el-table-column label="加分类型" align="center" prop="bonusType" />
36
+      <el-table-column label="加分" align="center" prop="addScore">
37
+        <template #default="{ row }"><span v-if="row.addScore" style="color:#67c23a">+{{ row.addScore }}</span></template>
38
+      </el-table-column>
39
+      <el-table-column label="附件" align="center" prop="evidenceFile">
40
+        <template #default="{ row }">
41
+          <el-link v-if="row.evidenceFile" :href="row.evidenceFile" target="_blank" type="primary">查看</el-link>
42
+          <span v-else>-</span>
43
+        </template>
44
+      </el-table-column>
45
+    </el-table>
46
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
47
+  </div>
48
+</template>
49
+<script setup>
50
+import { ref, reactive, onMounted } from 'vue'
51
+import { listTerminalBonus, exportTerminalBonus } from '@/api/ledger/index'
52
+import { parseTime } from '@/utils/ruoyi'
53
+defineOptions({ name: 'LedgerTerminalBonus' })
54
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
55
+const dateRange = ref([]), queryRef = ref(null)
56
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '', personName: '' })
57
+function getList() {
58
+  loading.value = true
59
+  const p = { ...queryParams }
60
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
61
+  listTerminalBonus(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
62
+}
63
+function handleQuery() { queryParams.pageNum = 1; getList() }
64
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
65
+function handleExport() {
66
+  const p = { ...queryParams }
67
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
68
+  exportTerminalBonus(p)
69
+}
70
+onMounted(getList)
71
+</script>

+ 64 - 0
src/views/ledger/unsafeEvent/index.vue

@@ -0,0 +1,64 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="部门名称" prop="deptName">
5
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="队室/班组" prop="teamName">
8
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="记录日期">
11
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
12
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
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
+    <el-row :gutter="10" class="mb8">
20
+      <el-col :span="1.5">
21
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['ledger:unsafeEvent:export']">导出</el-button>
22
+      </el-col>
23
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
24
+    </el-row>
25
+    <el-table v-loading="loading" :data="list">
26
+      <el-table-column label="事件日期" align="center" prop="recordDate" width="110">
27
+        <template #default="{ row }">{{ parseTime(row.recordDate, '{y}-{m}-{d}') }}</template>
28
+      </el-table-column>
29
+      <el-table-column label="部门名称" align="center" prop="deptName" />
30
+      <el-table-column label="队室/班组" align="center" prop="teamName" />
31
+      <el-table-column label="责任人" align="center" prop="responsibleName" />
32
+      <el-table-column label="事件类型" align="center" prop="eventType" />
33
+      <el-table-column label="事件描述" align="center" prop="eventDesc" show-overflow-tooltip />
34
+      <el-table-column label="处理结果" align="center" prop="resultHandling" show-overflow-tooltip />
35
+      <el-table-column label="扣分" align="center" prop="deductScore">
36
+        <template #default="{ row }"><span v-if="row.deductScore" style="color:#f56c6c">-{{ row.deductScore }}</span></template>
37
+      </el-table-column>
38
+    </el-table>
39
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
40
+  </div>
41
+</template>
42
+<script setup>
43
+import { ref, reactive, onMounted } from 'vue'
44
+import { listUnsafeEvent, exportUnsafeEvent } from '@/api/ledger/index'
45
+import { parseTime } from '@/utils/ruoyi'
46
+defineOptions({ name: 'LedgerUnsafeEvent' })
47
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
48
+const dateRange = ref([]), queryRef = ref(null)
49
+const queryParams = reactive({ pageNum: 1, pageSize: 10, deptName: '', teamName: '' })
50
+function getList() {
51
+  loading.value = true
52
+  const p = { ...queryParams }
53
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
54
+  listUnsafeEvent(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
55
+}
56
+function handleQuery() { queryParams.pageNum = 1; getList() }
57
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
58
+function handleExport() {
59
+  const p = { ...queryParams }
60
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
61
+  exportUnsafeEvent(p)
62
+}
63
+onMounted(getList)
64
+</script>

+ 309 - 0
src/views/score/dimension/index.vue

@@ -0,0 +1,309 @@
1
+<template>
2
+  <div class="app-container score-dimension-page">
3
+    <el-row :gutter="16" style="height:100%">
4
+      <!-- 左侧:维度列表 -->
5
+      <el-col :span="7">
6
+        <el-card class="dim-card" shadow="never">
7
+          <template #header>
8
+            <div class="card-header">
9
+              <span>评分维度(6个)</span>
10
+              <el-button type="primary" size="small" icon="Plus" @click="handleAddDim" v-hasPermi="['score:dimension:add']">新增</el-button>
11
+            </div>
12
+          </template>
13
+          <div v-loading="dimLoading">
14
+            <div v-for="dim in dimList" :key="dim.id"
15
+                 :class="['dim-item', { active: selectedDim && selectedDim.id === dim.id }]"
16
+                 @click="selectDimension(dim)">
17
+              <div class="dim-name">
18
+                <el-tag :type="dim.status === '0' ? '' : 'info'" size="small" style="margin-right:6px">{{ dim.name }}</el-tag>
19
+                <span class="dim-weight">{{ dim.weight }}%</span>
20
+              </div>
21
+              <div class="dim-actions">
22
+                <el-button link size="small" icon="Edit" @click.stop="handleEditDim(dim)" v-hasPermi="['score:dimension:edit']" />
23
+                <el-button link size="small" icon="Delete" type="danger" @click.stop="handleDeleteDim(dim)" v-hasPermi="['score:dimension:remove']" />
24
+              </div>
25
+            </div>
26
+          </div>
27
+          <div class="weight-total">
28
+            权重合计:<strong :style="{ color: totalWeight === 100 ? '#67c23a' : '#f56c6c' }">{{ totalWeight }}%</strong>
29
+          </div>
30
+        </el-card>
31
+      </el-col>
32
+
33
+      <!-- 右侧:指标树 -->
34
+      <el-col :span="17">
35
+        <el-card shadow="never">
36
+          <template #header>
37
+            <div class="card-header">
38
+              <span>{{ selectedDim ? selectedDim.name + ' — 指标树' : '请选择左侧维度' }}</span>
39
+              <div v-if="selectedDim">
40
+                <el-button size="small" icon="Plus" type="primary" @click="handleAddIndicator(null)" v-hasPermi="['score:indicator:add']">新增二级指标</el-button>
41
+                <el-button size="small" icon="Refresh" @click="loadTree" />
42
+              </div>
43
+            </div>
44
+          </template>
45
+
46
+          <el-table v-loading="treeLoading" :data="indicatorTree" row-key="id"
47
+                    :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
48
+                    border default-expand-all>
49
+            <el-table-column label="指标名称" prop="name" min-width="220" />
50
+            <el-table-column label="层级" align="center" width="70">
51
+              <template #default="{ row }">
52
+                <el-tag size="small" :type="row.level === 2 ? 'primary' : row.level === 3 ? 'success' : 'warning'">
53
+                  {{ row.level }}级
54
+                </el-tag>
55
+              </template>
56
+            </el-table-column>
57
+            <el-table-column label="类型" align="center" width="80">
58
+              <template #default="{ row }">
59
+                <el-tag v-if="row.type" size="small" :type="row.type === '1' ? 'success' : row.type === '2' ? 'danger' : 'info'">
60
+                  {{ row.type === '1' ? '加分' : row.type === '2' ? '扣分' : '记录' }}
61
+                </el-tag>
62
+              </template>
63
+            </el-table-column>
64
+            <el-table-column label="分值" align="center" width="90">
65
+              <template #default="{ row }">
66
+                <span v-if="row.scoreValue != null"
67
+                      :style="{ color: row.scoreValue > 0 ? '#67c23a' : row.scoreValue < 0 ? '#f56c6c' : '' }">
68
+                  {{ row.scoreValue > 0 ? '+' : '' }}{{ row.scoreValue }}
69
+                </span>
70
+              </template>
71
+            </el-table-column>
72
+            <el-table-column label="叠加规则" prop="cascadeRule" min-width="180" show-overflow-tooltip />
73
+            <el-table-column label="状态" align="center" width="80">
74
+              <template #default="{ row }">
75
+                <el-switch v-model="row.status" active-value="0" inactive-value="1"
76
+                           @change="handleStatusChange(row)" v-hasPermi="['score:indicator:edit']" />
77
+              </template>
78
+            </el-table-column>
79
+            <el-table-column label="操作" align="center" width="160">
80
+              <template #default="{ row }">
81
+                <el-button v-if="row.level < 4" link type="primary" size="small" icon="Plus"
82
+                           @click="handleAddIndicator(row)" v-hasPermi="['score:indicator:add']">子级</el-button>
83
+                <el-button link type="primary" size="small" icon="Edit"
84
+                           @click="handleEditIndicator(row)" v-hasPermi="['score:indicator:edit']">修改</el-button>
85
+                <el-button link type="danger" size="small" icon="Delete"
86
+                           @click="handleDeleteIndicator(row)" v-hasPermi="['score:indicator:remove']">删除</el-button>
87
+              </template>
88
+            </el-table-column>
89
+          </el-table>
90
+        </el-card>
91
+      </el-col>
92
+    </el-row>
93
+
94
+    <!-- 维度对话框 -->
95
+    <el-dialog :title="dimDialogTitle" v-model="dimDialogVisible" width="480px" append-to-body>
96
+      <el-form ref="dimFormRef" :model="dimForm" :rules="dimRules" label-width="90px">
97
+        <el-form-item label="维度名称" prop="name">
98
+          <el-input v-model="dimForm.name" placeholder="请输入维度名称" />
99
+        </el-form-item>
100
+        <el-form-item label="权重(%)" prop="weight">
101
+          <el-input-number v-model="dimForm.weight" :precision="2" :min="0" :max="100" style="width:100%" />
102
+        </el-form-item>
103
+        <el-form-item label="基础分" prop="baseScore">
104
+          <el-input-number v-model="dimForm.baseScore" :precision="2" :min="0" style="width:100%" />
105
+        </el-form-item>
106
+        <el-form-item label="排序" prop="sortOrder">
107
+          <el-input-number v-model="dimForm.sortOrder" :min="0" style="width:100%" />
108
+        </el-form-item>
109
+        <el-form-item label="状态">
110
+          <el-radio-group v-model="dimForm.status">
111
+            <el-radio value="0">正常</el-radio>
112
+            <el-radio value="1">停用</el-radio>
113
+          </el-radio-group>
114
+        </el-form-item>
115
+        <el-form-item label="备注">
116
+          <el-input v-model="dimForm.remark" type="textarea" :rows="2" />
117
+        </el-form-item>
118
+      </el-form>
119
+      <template #footer>
120
+        <el-button @click="dimDialogVisible = false">取消</el-button>
121
+        <el-button type="primary" @click="submitDimForm">确定</el-button>
122
+      </template>
123
+    </el-dialog>
124
+
125
+    <!-- 指标对话框 -->
126
+    <el-dialog :title="indDialogTitle" v-model="indDialogVisible" width="540px" append-to-body>
127
+      <el-form ref="indFormRef" :model="indForm" :rules="indRules" label-width="100px">
128
+        <el-form-item label="所属维度">
129
+          <el-input :value="selectedDim ? selectedDim.name : ''" disabled />
130
+        </el-form-item>
131
+        <el-form-item label="父级指标" v-if="indForm.parentId && indForm.parentId !== 0">
132
+          <el-input :value="parentIndicatorName" disabled />
133
+        </el-form-item>
134
+        <el-form-item label="层级">
135
+          <el-tag>{{ indForm.level }}级</el-tag>
136
+        </el-form-item>
137
+        <el-form-item label="指标名称" prop="name">
138
+          <el-input v-model="indForm.name" placeholder="请输入指标名称" />
139
+        </el-form-item>
140
+        <el-form-item label="类型" prop="type">
141
+          <el-radio-group v-model="indForm.type">
142
+            <el-radio value="1">加分</el-radio>
143
+            <el-radio value="2">扣分</el-radio>
144
+            <el-radio value="3">纯记录</el-radio>
145
+          </el-radio-group>
146
+        </el-form-item>
147
+        <el-form-item label="分值" prop="scoreValue">
148
+          <el-input-number v-model="indForm.scoreValue" :precision="2" style="width:100%"
149
+                           placeholder="正数=加分,负数=扣分" />
150
+        </el-form-item>
151
+        <el-form-item label="叠加规则">
152
+          <el-input v-model="indForm.cascadeRule" type="textarea" :rows="2"
153
+                    placeholder="如:返航/二次清舱-10、航班延误-8..." />
154
+        </el-form-item>
155
+        <el-form-item label="排序">
156
+          <el-input-number v-model="indForm.sortOrder" :min="0" style="width:100%" />
157
+        </el-form-item>
158
+        <el-form-item label="状态">
159
+          <el-radio-group v-model="indForm.status">
160
+            <el-radio value="0">正常</el-radio>
161
+            <el-radio value="1">停用</el-radio>
162
+          </el-radio-group>
163
+        </el-form-item>
164
+        <el-form-item label="备注">
165
+          <el-input v-model="indForm.remark" type="textarea" :rows="2" />
166
+        </el-form-item>
167
+      </el-form>
168
+      <template #footer>
169
+        <el-button @click="indDialogVisible = false">取消</el-button>
170
+        <el-button type="primary" @click="submitIndForm">确定</el-button>
171
+      </template>
172
+    </el-dialog>
173
+  </div>
174
+</template>
175
+
176
+<script setup>
177
+import { ref, reactive, computed, onMounted } from 'vue'
178
+import { ElMessage, ElMessageBox } from 'element-plus'
179
+import {
180
+  listDimension, addDimension, updateDimension, delDimension,
181
+  treeIndicator, addIndicator, updateIndicator, delIndicator
182
+} from '@/api/score/index'
183
+
184
+defineOptions({ name: 'ScoreDimension' })
185
+
186
+// ===== 维度 =====
187
+const dimLoading = ref(false)
188
+const dimList = ref([])
189
+const selectedDim = ref(null)
190
+const dimDialogVisible = ref(false)
191
+const dimDialogTitle = ref('')
192
+const dimFormRef = ref(null)
193
+const dimForm = reactive({ id: null, name: '', weight: 0, baseScore: 80, sortOrder: 0, status: '0', remark: '' })
194
+const dimRules = {
195
+  name: [{ required: true, message: '请输入维度名称', trigger: 'blur' }],
196
+  weight: [{ required: true, message: '请输入权重', trigger: 'blur' }]
197
+}
198
+
199
+const totalWeight = computed(() => {
200
+  return dimList.value.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
201
+})
202
+
203
+async function loadDimensions() {
204
+  dimLoading.value = true
205
+  const r = await listDimension({ pageNum: 1, pageSize: 100 }).finally(() => dimLoading.value = false)
206
+  dimList.value = r.rows || []
207
+  if (!selectedDim.value && dimList.value.length) selectDimension(dimList.value[0])
208
+}
209
+
210
+function selectDimension(dim) {
211
+  selectedDim.value = dim
212
+  loadTree()
213
+}
214
+
215
+function handleAddDim() {
216
+  Object.assign(dimForm, { id: null, name: '', weight: 0, baseScore: 80, sortOrder: dimList.value.length + 1, status: '0', remark: '' })
217
+  dimDialogTitle.value = '新增维度'
218
+  dimDialogVisible.value = true
219
+}
220
+
221
+function handleEditDim(dim) {
222
+  Object.assign(dimForm, dim)
223
+  dimDialogTitle.value = '修改维度'
224
+  dimDialogVisible.value = true
225
+}
226
+
227
+async function submitDimForm() {
228
+  await dimFormRef.value.validate()
229
+  if (dimForm.id) { await updateDimension(dimForm); ElMessage.success('修改成功') }
230
+  else { await addDimension(dimForm); ElMessage.success('新增成功') }
231
+  dimDialogVisible.value = false
232
+  loadDimensions()
233
+}
234
+
235
+function handleDeleteDim(dim) {
236
+  ElMessageBox.confirm(`确认删除维度「${dim.name}」?删除后其下所有指标也将不可用。`, '警告', { type: 'warning' }).then(() => {
237
+    delDimension(dim.id).then(() => { ElMessage.success('删除成功'); loadDimensions() })
238
+  })
239
+}
240
+
241
+// ===== 指标树 =====
242
+const treeLoading = ref(false)
243
+const indicatorTree = ref([])
244
+const indDialogVisible = ref(false)
245
+const indDialogTitle = ref('')
246
+const indFormRef = ref(null)
247
+const indForm = reactive({ id: null, dimensionId: null, parentId: 0, level: 2, name: '', type: '2', scoreValue: 0, cascadeRule: '', sortOrder: 0, status: '0', remark: '' })
248
+const indRules = { name: [{ required: true, message: '请输入指标名称', trigger: 'blur' }] }
249
+const parentIndicatorName = ref('')
250
+
251
+async function loadTree() {
252
+  if (!selectedDim.value) return
253
+  treeLoading.value = true
254
+  const r = await treeIndicator(selectedDim.value.id).finally(() => treeLoading.value = false)
255
+  indicatorTree.value = r.data || []
256
+}
257
+
258
+function handleAddIndicator(parentRow) {
259
+  Object.assign(indForm, { id: null, dimensionId: selectedDim.value.id, parentId: parentRow ? parentRow.id : 0, level: parentRow ? parentRow.level + 1 : 2, name: '', type: '2', scoreValue: 0, cascadeRule: '', sortOrder: 0, status: '0', remark: '' })
260
+  parentIndicatorName.value = parentRow ? parentRow.name : ''
261
+  indDialogTitle.value = '新增' + (parentRow ? parentRow.level + 1 : 2) + '级指标'
262
+  indDialogVisible.value = true
263
+}
264
+
265
+function handleEditIndicator(row) {
266
+  Object.assign(indForm, row)
267
+  parentIndicatorName.value = ''
268
+  indDialogTitle.value = '修改指标'
269
+  indDialogVisible.value = true
270
+}
271
+
272
+async function submitIndForm() {
273
+  await indFormRef.value.validate()
274
+  if (indForm.id) { await updateIndicator(indForm); ElMessage.success('修改成功') }
275
+  else { await addIndicator(indForm); ElMessage.success('新增成功') }
276
+  indDialogVisible.value = false
277
+  loadTree()
278
+}
279
+
280
+async function handleStatusChange(row) {
281
+  await updateIndicator({ id: row.id, status: row.status })
282
+  ElMessage.success(row.status === '0' ? '已启用' : '已停用')
283
+}
284
+
285
+function handleDeleteIndicator(row) {
286
+  ElMessageBox.confirm(`确认删除指标「${row.name}」?如有子指标也将一并删除。`, '警告', { type: 'warning' }).then(() => {
287
+    delIndicator(row.id).then(() => { ElMessage.success('删除成功'); loadTree() })
288
+  })
289
+}
290
+
291
+onMounted(loadDimensions)
292
+</script>
293
+
294
+<style scoped>
295
+.score-dimension-page { height: calc(100vh - 130px); }
296
+.dim-card { height: 100%; }
297
+.card-header { display: flex; justify-content: space-between; align-items: center; }
298
+.dim-item {
299
+  display: flex; justify-content: space-between; align-items: center;
300
+  padding: 10px 12px; border-radius: 6px; cursor: pointer;
301
+  margin-bottom: 6px; border: 1px solid #ebeef5; transition: all .2s;
302
+}
303
+.dim-item:hover { border-color: #409eff; background: #f0f7ff; }
304
+.dim-item.active { border-color: #409eff; background: #ecf5ff; }
305
+.dim-name { display: flex; align-items: center; }
306
+.dim-weight { font-size: 12px; color: #909399; }
307
+.dim-actions { display: flex; gap: 4px; }
308
+.weight-total { text-align: right; margin-top: 12px; font-size: 13px; color: #606266; }
309
+</style>

+ 320 - 0
src/views/score/event/index.vue

@@ -0,0 +1,320 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="80px">
4
+      <el-form-item label="责任人" prop="personName">
5
+        <el-input v-model="queryParams.personName" placeholder="请输入责任人" clearable @keyup.enter="handleQuery" />
6
+      </el-form-item>
7
+      <el-form-item label="部门名称" prop="deptName">
8
+        <el-input v-model="queryParams.deptName" placeholder="请输入部门名称" clearable @keyup.enter="handleQuery" />
9
+      </el-form-item>
10
+      <el-form-item label="队室/班组" prop="teamName">
11
+        <el-input v-model="queryParams.teamName" placeholder="请输入队室/班组" clearable @keyup.enter="handleQuery" />
12
+      </el-form-item>
13
+      <el-form-item label="维度" prop="dimensionId">
14
+        <el-select v-model="queryParams.dimensionId" placeholder="全部维度" clearable style="width:150px">
15
+          <el-option v-for="d in dimensionOptions" :key="d.id" :label="d.name" :value="d.id" />
16
+        </el-select>
17
+      </el-form-item>
18
+      <el-form-item label="来源" prop="sourceType">
19
+        <el-select v-model="queryParams.sourceType" placeholder="全部来源" clearable style="width:120px">
20
+          <el-option label="手动录入" value="1" />
21
+          <el-option label="台账同步" value="2" />
22
+        </el-select>
23
+      </el-form-item>
24
+      <el-form-item label="事件时间">
25
+        <el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
26
+          range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
27
+      </el-form-item>
28
+      <el-form-item>
29
+        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
30
+        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
31
+      </el-form-item>
32
+    </el-form>
33
+
34
+    <el-row :gutter="10" class="mb8">
35
+      <el-col :span="1.5">
36
+        <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['score:event:add']">新增</el-button>
37
+      </el-col>
38
+      <el-col :span="1.5">
39
+        <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['score:event:edit']">修改</el-button>
40
+      </el-col>
41
+      <el-col :span="1.5">
42
+        <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['score:event:remove']">删除</el-button>
43
+      </el-col>
44
+      <el-col :span="1.5">
45
+        <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['score:event:export']">导出</el-button>
46
+      </el-col>
47
+      <el-col :span="1.5">
48
+        <el-upload :show-file-list="false" accept=".xlsx,.xls" :auto-upload="false" :on-change="handleImportFile">
49
+          <el-button type="info" plain icon="Upload" v-hasPermi="['score:event:import']">导入</el-button>
50
+        </el-upload>
51
+      </el-col>
52
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
53
+    </el-row>
54
+
55
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange">
56
+      <el-table-column type="selection" width="55" align="center" />
57
+      <el-table-column label="事件时间" align="center" prop="eventTime" width="120">
58
+        <template #default="{ row }">{{ parseTime(row.eventTime, '{y}-{m}-{d}') }}</template>
59
+      </el-table-column>
60
+      <el-table-column label="维度" align="center" prop="dimensionName" width="110" />
61
+      <el-table-column label="二级指标" align="center" prop="level2Name" show-overflow-tooltip />
62
+      <el-table-column label="三级指标" align="center" prop="level3Name" show-overflow-tooltip />
63
+      <el-table-column label="责任人" align="center" prop="personName" width="80" />
64
+      <el-table-column label="部门" align="center" prop="deptName" />
65
+      <el-table-column label="班组" align="center" prop="teamName" />
66
+      <el-table-column label="基础分值" align="center" prop="scoreValue" width="90">
67
+        <template #default="{ row }">
68
+          <span :style="{ color: row.scoreValue > 0 ? '#67c23a' : row.scoreValue < 0 ? '#f56c6c' : '' }">
69
+            {{ row.scoreValue > 0 ? '+' : '' }}{{ row.scoreValue }}
70
+          </span>
71
+        </template>
72
+      </el-table-column>
73
+      <el-table-column label="叠加分" align="center" prop="cascadeScore" width="80">
74
+        <template #default="{ row }">
75
+          <span v-if="row.cascadeScore" :style="{ color: row.cascadeScore > 0 ? '#67c23a' : '#f56c6c' }">
76
+            {{ row.cascadeScore > 0 ? '+' : '' }}{{ row.cascadeScore }}
77
+          </span>
78
+          <span v-else style="color:#c0c4cc">-</span>
79
+        </template>
80
+      </el-table-column>
81
+      <el-table-column label="总分值" align="center" prop="totalScore" width="90">
82
+        <template #default="{ row }">
83
+          <strong :style="{ color: row.totalScore > 0 ? '#67c23a' : row.totalScore < 0 ? '#f56c6c' : '' }">
84
+            {{ row.totalScore > 0 ? '+' : '' }}{{ row.totalScore }}
85
+          </strong>
86
+        </template>
87
+      </el-table-column>
88
+      <el-table-column label="来源" align="center" width="90">
89
+        <template #default="{ row }">
90
+          <el-tag :type="row.sourceType === '1' ? 'primary' : 'warning'" size="small">
91
+            {{ row.sourceType === '1' ? '手动录入' : '台账同步' }}
92
+          </el-tag>
93
+        </template>
94
+      </el-table-column>
95
+      <el-table-column label="事件描述" align="center" prop="eventDesc" show-overflow-tooltip />
96
+      <el-table-column label="操作" align="center" width="120">
97
+        <template #default="{ row }">
98
+          <el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['score:event:edit']">修改</el-button>
99
+          <el-button link type="danger" icon="Delete" @click="handleDelete(row)" v-hasPermi="['score:event:remove']">删除</el-button>
100
+        </template>
101
+      </el-table-column>
102
+    </el-table>
103
+    <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
104
+
105
+    <!-- 新增/修改对话框 -->
106
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="680px" append-to-body>
107
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
108
+        <el-row :gutter="16">
109
+          <el-col :span="12">
110
+            <el-form-item label="维度" prop="dimensionId">
111
+              <el-select v-model="form.dimensionId" placeholder="请选择维度" style="width:100%" @change="onDimensionChange">
112
+                <el-option v-for="d in dimensionOptions" :key="d.id" :label="d.name" :value="d.id" />
113
+              </el-select>
114
+            </el-form-item>
115
+          </el-col>
116
+          <el-col :span="12">
117
+            <el-form-item label="二级指标" prop="level2Name">
118
+              <el-select v-model="form.level2Name" placeholder="请选择" style="width:100%" clearable @change="onLevel2Change">
119
+                <el-option v-for="n in level2Options" :key="n" :label="n" :value="n" />
120
+              </el-select>
121
+            </el-form-item>
122
+          </el-col>
123
+          <el-col :span="12">
124
+            <el-form-item label="三级指标" prop="level3Name">
125
+              <el-select v-model="form.level3Name" placeholder="请选择" style="width:100%" clearable @change="onLevel3Change">
126
+                <el-option v-for="n in level3Options" :key="n" :label="n" :value="n" />
127
+              </el-select>
128
+            </el-form-item>
129
+          </el-col>
130
+          <el-col :span="12">
131
+            <el-form-item label="四级指标">
132
+              <el-select v-model="form.level4Name" placeholder="请选择" style="width:100%" clearable>
133
+                <el-option v-for="n in level4Options" :key="n" :label="n" :value="n" />
134
+              </el-select>
135
+            </el-form-item>
136
+          </el-col>
137
+          <el-col :span="12">
138
+            <el-form-item label="事件时间" prop="eventTime">
139
+              <el-date-picker v-model="form.eventTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
140
+                placeholder="请选择事件时间" style="width:100%" />
141
+            </el-form-item>
142
+          </el-col>
143
+          <el-col :span="12">
144
+            <el-form-item label="位置">
145
+              <el-input v-model="form.location" placeholder="区域/通道" />
146
+            </el-form-item>
147
+          </el-col>
148
+          <el-col :span="12">
149
+            <el-form-item label="责任人" prop="personName">
150
+              <el-input v-model="form.personName" placeholder="请输入责任人姓名" />
151
+            </el-form-item>
152
+          </el-col>
153
+          <el-col :span="12">
154
+            <el-form-item label="部门名称">
155
+              <el-input v-model="form.deptName" placeholder="请输入部门名称" />
156
+            </el-form-item>
157
+          </el-col>
158
+          <el-col :span="12">
159
+            <el-form-item label="队室/班组">
160
+              <el-input v-model="form.teamName" placeholder="请输入队室/班组" />
161
+            </el-form-item>
162
+          </el-col>
163
+          <el-col :span="12">
164
+            <el-form-item label="小组">
165
+              <el-input v-model="form.groupName" placeholder="请输入小组" />
166
+            </el-form-item>
167
+          </el-col>
168
+          <el-col :span="12">
169
+            <el-form-item label="基础分值" prop="scoreValue">
170
+              <el-input-number v-model="form.scoreValue" :precision="2" style="width:100%"
171
+                               placeholder="正数=加分 负数=扣分" />
172
+            </el-form-item>
173
+          </el-col>
174
+          <el-col :span="12">
175
+            <el-form-item label="叠加分值">
176
+              <el-input-number v-model="form.cascadeScore" :precision="2" style="width:100%"
177
+                               placeholder="叠加后果产生的分值" />
178
+            </el-form-item>
179
+          </el-col>
180
+          <el-col :span="24">
181
+            <el-form-item label="事件描述" prop="eventDesc">
182
+              <el-input v-model="form.eventDesc" type="textarea" :rows="3" placeholder="请输入事件描述" />
183
+            </el-form-item>
184
+          </el-col>
185
+          <el-col :span="24">
186
+            <el-form-item label="备注">
187
+              <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
188
+            </el-form-item>
189
+          </el-col>
190
+        </el-row>
191
+      </el-form>
192
+      <template #footer>
193
+        <el-button @click="dialogVisible = false">取消</el-button>
194
+        <el-button type="primary" @click="submitForm">确定</el-button>
195
+      </template>
196
+    </el-dialog>
197
+  </div>
198
+</template>
199
+
200
+<script setup>
201
+import { ref, reactive, onMounted } from 'vue'
202
+import { ElMessage, ElMessageBox } from 'element-plus'
203
+import {
204
+  listScoreEvent, addScoreEvent, updateScoreEvent, delScoreEvent,
205
+  exportScoreEvent, importScoreEvent
206
+} from '@/api/score/index'
207
+import { allDimension, treeIndicator } from '@/api/score/index'
208
+import { parseTime } from '@/utils/ruoyi'
209
+
210
+defineOptions({ name: 'ScoreEvent' })
211
+
212
+const loading = ref(false), list = ref([]), total = ref(0), showSearch = ref(true)
213
+const dateRange = ref([]), queryRef = ref(null), formRef = ref(null)
214
+const dialogVisible = ref(false), dialogTitle = ref('')
215
+const single = ref(true), multiple = ref(true), ids = ref([])
216
+const dimensionOptions = ref([])
217
+const indicatorTree = ref([])  // 当前维度的指标树(扁平+嵌套)
218
+const level2Options = ref([])
219
+const level3Options = ref([])
220
+const level4Options = ref([])
221
+
222
+const queryParams = reactive({ pageNum: 1, pageSize: 10, personName: '', deptName: '', teamName: '', dimensionId: null, sourceType: '' })
223
+const form = reactive({ id: null, dimensionId: null, dimensionName: '', indicatorId: null, level2Name: '', level3Name: '', level4Name: '', eventTime: '', location: '', personName: '', deptName: '', teamName: '', groupName: '', scoreValue: 0, cascadeScore: 0, eventDesc: '', remark: '' })
224
+const rules = {
225
+  dimensionId: [{ required: true, message: '请选择维度', trigger: 'change' }],
226
+  personName: [{ required: true, message: '请输入责任人', trigger: 'blur' }],
227
+  eventTime: [{ required: true, message: '请选择事件时间', trigger: 'change' }],
228
+  scoreValue: [{ required: true, message: '请输入分值', trigger: 'blur' }]
229
+}
230
+
231
+async function loadDimensions() {
232
+  const r = await allDimension()
233
+  dimensionOptions.value = r.data || []
234
+}
235
+
236
+function getList() {
237
+  loading.value = true
238
+  const p = { ...queryParams }
239
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
240
+  listScoreEvent(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
241
+}
242
+
243
+function handleQuery() { queryParams.pageNum = 1; getList() }
244
+function resetQuery() { dateRange.value = []; queryRef.value?.resetFields(); handleQuery() }
245
+function handleSelectionChange(sel) { ids.value = sel.map(s => s.id); single.value = sel.length !== 1; multiple.value = !sel.length }
246
+
247
+function resetForm() {
248
+  Object.assign(form, { id: null, dimensionId: null, dimensionName: '', indicatorId: null, level2Name: '', level3Name: '', level4Name: '', eventTime: '', location: '', personName: '', deptName: '', teamName: '', groupName: '', scoreValue: 0, cascadeScore: 0, eventDesc: '', remark: '' })
249
+  level2Options.value = []; level3Options.value = []; level4Options.value = []
250
+}
251
+
252
+function handleAdd() { resetForm(); dialogTitle.value = '新增配分事项'; dialogVisible.value = true }
253
+function handleUpdate(row) {
254
+  resetForm()
255
+  const record = row?.id ? row : list.value.find(r => r.id === ids.value[0])
256
+  if (record) {
257
+    Object.assign(form, record)
258
+    if (record.dimensionId) onDimensionChange(record.dimensionId, true)
259
+  }
260
+  dialogTitle.value = '修改配分事项'; dialogVisible.value = true
261
+}
262
+
263
+async function onDimensionChange(dimId, preserveSelection) {
264
+  const dim = dimensionOptions.value.find(d => d.id === dimId)
265
+  if (dim) form.dimensionName = dim.name
266
+  const r = await treeIndicator(dimId)
267
+  indicatorTree.value = r.data || []
268
+  level2Options.value = indicatorTree.value.map(n => n.name)
269
+  if (!preserveSelection) { form.level2Name = ''; form.level3Name = ''; form.level4Name = '' }
270
+  level3Options.value = []; level4Options.value = []
271
+  if (preserveSelection && form.level2Name) onLevel2Change(form.level2Name, true)
272
+}
273
+
274
+function onLevel2Change(val, preserveSelection) {
275
+  const node = indicatorTree.value.find(n => n.name === val)
276
+  level3Options.value = node ? (node.children || []).map(n => n.name) : []
277
+  if (!preserveSelection) { form.level3Name = ''; form.level4Name = '' }
278
+  level4Options.value = []
279
+  if (preserveSelection && form.level3Name) onLevel3Change(form.level3Name, true)
280
+}
281
+
282
+function onLevel3Change(val, preserveSelection) {
283
+  const level2Node = indicatorTree.value.find(n => n.name === form.level2Name)
284
+  const node = level2Node ? (level2Node.children || []).find(n => n.name === val) : null
285
+  level4Options.value = node ? (node.children || []).map(n => n.name) : []
286
+  if (!preserveSelection) form.level4Name = ''
287
+}
288
+
289
+async function submitForm() {
290
+  await formRef.value.validate()
291
+  if (form.id) { await updateScoreEvent(form); ElMessage.success('修改成功') }
292
+  else { await addScoreEvent(form); ElMessage.success('新增成功') }
293
+  dialogVisible.value = false; getList()
294
+}
295
+
296
+function handleDelete(row) {
297
+  const delIds = row?.id ? [row.id] : ids.value
298
+  ElMessageBox.confirm('确认删除选中的配分事项?', '提示', { type: 'warning' }).then(() => {
299
+    delScoreEvent(delIds.join(',')).then(() => { ElMessage.success('删除成功'); getList() })
300
+  })
301
+}
302
+
303
+function handleExport() {
304
+  const p = { ...queryParams }
305
+  if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
306
+  exportScoreEvent(p)
307
+}
308
+
309
+async function handleImportFile(file) {
310
+  try {
311
+    const r = await importScoreEvent(file.raw)
312
+    ElMessage.success(r.msg || '导入成功')
313
+    getList()
314
+  } catch (e) {
315
+    ElMessage.error('导入失败')
316
+  }
317
+}
318
+
319
+onMounted(() => { loadDimensions(); getList() })
320
+</script>

+ 174 - 0
src/views/score/pushConfig/index.vue

@@ -0,0 +1,174 @@
1
+<template>
2
+  <div class="app-container">
3
+    <el-row :gutter="10" class="mb8">
4
+      <el-col :span="1.5">
5
+        <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['score:pushConfig:add']">新增配置</el-button>
6
+      </el-col>
7
+    </el-row>
8
+
9
+    <!-- 按维度分组展示推送配置 -->
10
+    <div v-loading="loading">
11
+      <el-card v-for="dim in dimensionOptions" :key="dim.id" class="dim-group-card" shadow="never">
12
+        <template #header>
13
+          <div class="group-header">
14
+            <span class="dim-title">{{ dim.name }}</span>
15
+            <span class="dim-weight">权重 {{ dim.weight }}% | 基础分 {{ dim.baseScore }}</span>
16
+            <el-button size="small" type="primary" plain icon="Plus"
17
+                       @click="handleAddForDim(dim)" v-hasPermi="['score:pushConfig:add']">添加推送规则</el-button>
18
+          </div>
19
+        </template>
20
+        <el-table :data="getConfigsByDim(dim.id)" border>
21
+          <el-table-column label="适用部门" align="center" prop="deptName">
22
+            <template #default="{ row }">{{ row.deptName || '全局(所有部门)' }}</template>
23
+          </el-table-column>
24
+          <el-table-column label="预警阈值" align="center" width="120">
25
+            <template #default="{ row }">
26
+              <span style="color:#f56c6c;font-weight:bold">低于 {{ row.warnThreshold }} 分</span>
27
+            </template>
28
+          </el-table-column>
29
+          <el-table-column label="推送人员" align="center" prop="pushUserNames" show-overflow-tooltip />
30
+          <el-table-column label="状态" align="center" width="90">
31
+            <template #default="{ row }">
32
+              <el-switch v-model="row.status" active-value="0" inactive-value="1"
33
+                         @change="handleStatusChange(row)" v-hasPermi="['score:pushConfig:edit']" />
34
+            </template>
35
+          </el-table-column>
36
+          <el-table-column label="备注" align="center" prop="remark" show-overflow-tooltip />
37
+          <el-table-column label="操作" align="center" width="120">
38
+            <template #default="{ row }">
39
+              <el-button link type="primary" icon="Edit" @click="handleEdit(row)" v-hasPermi="['score:pushConfig:edit']">修改</el-button>
40
+              <el-button link type="danger" icon="Delete" @click="handleDelete(row)" v-hasPermi="['score:pushConfig:remove']">删除</el-button>
41
+            </template>
42
+          </el-table-column>
43
+        </el-table>
44
+        <div v-if="!getConfigsByDim(dim.id).length" class="no-config">
45
+          暂无推送规则,点击「添加推送规则」进行配置
46
+        </div>
47
+      </el-card>
48
+    </div>
49
+
50
+    <!-- 新增/修改对话框 -->
51
+    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="520px" append-to-body>
52
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
53
+        <el-form-item label="维度" prop="dimensionId">
54
+          <el-select v-model="form.dimensionId" placeholder="请选择维度" style="width:100%" @change="onDimChange">
55
+            <el-option v-for="d in dimensionOptions" :key="d.id" :label="d.name" :value="d.id" />
56
+          </el-select>
57
+        </el-form-item>
58
+        <el-form-item label="适用部门">
59
+          <el-input v-model="form.deptName" placeholder="留空=全局,填写部门名称=仅该部门" />
60
+        </el-form-item>
61
+        <el-form-item label="预警阈值" prop="warnThreshold">
62
+          <el-input-number v-model="form.warnThreshold" :precision="2" :min="0" :max="200" style="width:100%" />
63
+          <div style="color:#909399;font-size:12px;margin-top:4px">低于此分值时触发推送(默认 75 分)</div>
64
+        </el-form-item>
65
+        <el-form-item label="推送人员">
66
+          <el-input v-model="form.pushUserNames" placeholder="请输入推送人员姓名,多人用逗号分隔" />
67
+        </el-form-item>
68
+        <el-form-item label="状态">
69
+          <el-radio-group v-model="form.status">
70
+            <el-radio value="0">启用</el-radio>
71
+            <el-radio value="1">停用</el-radio>
72
+          </el-radio-group>
73
+        </el-form-item>
74
+        <el-form-item label="备注">
75
+          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
76
+        </el-form-item>
77
+      </el-form>
78
+      <template #footer>
79
+        <el-button @click="dialogVisible = false">取消</el-button>
80
+        <el-button type="primary" @click="submitForm">确定</el-button>
81
+      </template>
82
+    </el-dialog>
83
+  </div>
84
+</template>
85
+
86
+<script setup>
87
+import { ref, reactive, onMounted } from 'vue'
88
+import { ElMessage, ElMessageBox } from 'element-plus'
89
+import { listPushConfig, addPushConfig, updatePushConfig, delPushConfig } from '@/api/score/index'
90
+import { allDimension } from '@/api/score/index'
91
+
92
+defineOptions({ name: 'ScorePushConfig' })
93
+
94
+const loading = ref(false)
95
+const configList = ref([])
96
+const dimensionOptions = ref([])
97
+const dialogVisible = ref(false)
98
+const dialogTitle = ref('')
99
+const formRef = ref(null)
100
+const form = reactive({ id: null, dimensionId: null, dimensionName: '', deptId: null, deptName: '', warnThreshold: 75, pushUserIds: '', pushUserNames: '', status: '0', remark: '' })
101
+const rules = {
102
+  dimensionId: [{ required: true, message: '请选择维度', trigger: 'change' }],
103
+  warnThreshold: [{ required: true, message: '请输入预警阈值', trigger: 'blur' }]
104
+}
105
+
106
+async function loadDimensions() {
107
+  const r = await allDimension()
108
+  dimensionOptions.value = r.data || []
109
+}
110
+
111
+async function getList() {
112
+  loading.value = true
113
+  const r = await listPushConfig({ pageNum: 1, pageSize: 200 }).finally(() => loading.value = false)
114
+  configList.value = r.rows || []
115
+}
116
+
117
+function getConfigsByDim(dimId) {
118
+  return configList.value.filter(c => c.dimensionId === dimId)
119
+}
120
+
121
+function resetForm() {
122
+  Object.assign(form, { id: null, dimensionId: null, dimensionName: '', deptId: null, deptName: '', warnThreshold: 75, pushUserIds: '', pushUserNames: '', status: '0', remark: '' })
123
+}
124
+
125
+function handleAdd() { resetForm(); dialogTitle.value = '新增推送配置'; dialogVisible.value = true }
126
+
127
+function handleAddForDim(dim) {
128
+  resetForm()
129
+  form.dimensionId = dim.id
130
+  form.dimensionName = dim.name
131
+  dialogTitle.value = '新增推送配置 — ' + dim.name
132
+  dialogVisible.value = true
133
+}
134
+
135
+function handleEdit(row) {
136
+  Object.assign(form, row)
137
+  dialogTitle.value = '修改推送配置'
138
+  dialogVisible.value = true
139
+}
140
+
141
+function onDimChange(dimId) {
142
+  const dim = dimensionOptions.value.find(d => d.id === dimId)
143
+  if (dim) form.dimensionName = dim.name
144
+}
145
+
146
+async function submitForm() {
147
+  await formRef.value.validate()
148
+  if (form.id) { await updatePushConfig(form); ElMessage.success('修改成功') }
149
+  else { await addPushConfig(form); ElMessage.success('新增成功') }
150
+  dialogVisible.value = false
151
+  getList()
152
+}
153
+
154
+async function handleStatusChange(row) {
155
+  await updatePushConfig({ id: row.id, status: row.status })
156
+  ElMessage.success(row.status === '0' ? '已启用' : '已停用')
157
+}
158
+
159
+function handleDelete(row) {
160
+  ElMessageBox.confirm('确认删除此推送配置?', '提示', { type: 'warning' }).then(() => {
161
+    delPushConfig(row.id).then(() => { ElMessage.success('删除成功'); getList() })
162
+  })
163
+}
164
+
165
+onMounted(() => { loadDimensions(); getList() })
166
+</script>
167
+
168
+<style scoped>
169
+.dim-group-card { margin-bottom: 16px; }
170
+.group-header { display: flex; align-items: center; gap: 12px; }
171
+.dim-title { font-size: 15px; font-weight: bold; color: #303133; }
172
+.dim-weight { font-size: 13px; color: #909399; flex: 1; }
173
+.no-config { text-align: center; color: #c0c4cc; padding: 20px 0; font-size: 13px; }
174
+</style>

+ 432 - 0
src/views/score/radar/index.vue

@@ -0,0 +1,432 @@
1
+<template>
2
+  <div class="radar-screen">
3
+    <!-- 顶部标题栏 -->
4
+    <div class="screen-header">
5
+      <el-select v-model="selectedTeam" placeholder="请选择班组" class="team-select"
6
+                 @change="loadData" size="large">
7
+        <el-option v-for="t in teamList" :key="t" :label="t" :value="t" />
8
+      </el-select>
9
+      <div class="screen-title">班组成员画像大屏</div>
10
+      <div class="header-right">
11
+        <el-button
12
+          v-hasPermi="['ledger:sync:all']"
13
+          size="small" type="primary" plain
14
+          :loading="syncing"
15
+          class="sync-btn"
16
+          @click="handleSync">
17
+          同步台账
18
+        </el-button>
19
+      </div>
20
+    </div>
21
+
22
+    <!-- 主内容区 -->
23
+    <div v-loading="loading" element-loading-background="rgba(0,0,0,0.6)" class="screen-body">
24
+      <div class="member-grid">
25
+
26
+        <!-- 对比分析卡(双行高,始终在第一位) -->
27
+        <div class="member-card compare-card">
28
+          <div class="card-title-bar">对比分析</div>
29
+          <div ref="compareChartRef" class="compare-radar"></div>
30
+          <div class="compare-selector">
31
+            <span class="selector-label">选择对比者</span>
32
+            <el-tag
33
+              v-for="name in compareNames" :key="name"
34
+              closable effect="dark" type="warning"
35
+              class="compare-tag"
36
+              @close="removeCompare(name)">{{ name }}</el-tag>
37
+            <el-select v-if="compareNames.length < 2" v-model="addingName"
38
+                       placeholder="+请选择" size="small" class="add-select"
39
+                       @change="addCompare">
40
+              <el-option v-for="m in memberList" :key="m.personName"
41
+                         :label="m.personName" :value="m.personName" />
42
+            </el-select>
43
+          </div>
44
+          <!-- 对比数据表 -->
45
+          <table v-if="compareNames.length > 0" class="compare-table">
46
+            <thead>
47
+              <tr>
48
+                <th>维度</th>
49
+                <th v-for="name in compareNames" :key="name" class="col-person">{{ name }}</th>
50
+              </tr>
51
+            </thead>
52
+            <tbody>
53
+              <tr v-for="(dim, i) in dimensionNames" :key="i">
54
+                <td>{{ dim }}</td>
55
+                <td v-for="name in compareNames" :key="name"
56
+                    :class="getScoreClass(getContribution(name, i))">
57
+                  {{ getContribution(name, i) }}
58
+                </td>
59
+              </tr>
60
+            </tbody>
61
+          </table>
62
+        </div>
63
+
64
+        <!-- 成员卡片 -->
65
+        <div
66
+          v-for="member in memberList" :key="member.personName"
67
+          class="member-card"
68
+          :class="getMemberBorderClass(member.totalScore)">
69
+          <div class="card-name" :class="getNameClass(member.totalScore)">
70
+            {{ member.personName }}({{ member.totalScore }}分)
71
+          </div>
72
+          <div :ref="el => setChartRef(el, member.personName)" class="member-radar"></div>
73
+        </div>
74
+
75
+      </div>
76
+    </div>
77
+  </div>
78
+</template>
79
+
80
+<script setup>
81
+import { ref, reactive, onMounted, onBeforeUnmount, nextTick, computed, watch } from 'vue'
82
+import { ElMessage } from 'element-plus'
83
+import * as echarts from 'echarts'
84
+import { radarTeamList, radarMemberScores, syncLedgerAll } from '@/api/score/index'
85
+
86
+defineOptions({ name: 'ScoreRadar' })
87
+
88
+// ── 状态 ──────────────────────────────────────────────
89
+const loading = ref(false)
90
+const syncing = ref(false)
91
+const teamList = ref([])
92
+const selectedTeam = ref('')
93
+const memberList = ref([])
94
+
95
+// 对比
96
+const compareNames = ref([])
97
+const addingName = ref('')
98
+
99
+// ECharts 实例 map: personName → instance
100
+const chartMap = reactive({})
101
+// 对比图
102
+const compareChartRef = ref(null)
103
+let compareChart = null
104
+
105
+// member card refs: personName → DOM el
106
+const cardRefs = reactive({})
107
+
108
+// ── 计算属性 ──────────────────────────────────────────
109
+const dimensionNames = computed(() => {
110
+  if (!memberList.value.length) return []
111
+  return (memberList.value[0].dimensions || []).map(d => d.dimensionName)
112
+})
113
+
114
+// ── 颜色规范 ──────────────────────────────────────────
115
+function getMemberBorderClass(score) {
116
+  if (score >= 85) return 'border-green'
117
+  if (score < 75)  return 'border-red'
118
+  return 'border-normal'
119
+}
120
+function getNameClass(score) {
121
+  if (score >= 85) return 'name-green'
122
+  if (score < 75)  return 'name-red'
123
+  return ''
124
+}
125
+function getScoreClass(val) {
126
+  if (val == null) return ''
127
+  if (val >= 15) return 'score-green'
128
+  if (val < 10)  return 'score-red'
129
+  return ''
130
+}
131
+
132
+// ── 对比操作 ──────────────────────────────────────────
133
+function addCompare(name) {
134
+  if (!name || compareNames.value.includes(name)) return
135
+  if (compareNames.value.length >= 2) return
136
+  compareNames.value.push(name)
137
+  addingName.value = ''
138
+  nextTick(renderCompareChart)
139
+}
140
+function removeCompare(name) {
141
+  compareNames.value = compareNames.value.filter(n => n !== name)
142
+  nextTick(renderCompareChart)
143
+}
144
+
145
+function getContribution(personName, dimIndex) {
146
+  const m = memberList.value.find(x => x.personName === personName)
147
+  if (!m || !m.dimensions[dimIndex]) return '-'
148
+  return Number(m.dimensions[dimIndex].contribution)
149
+}
150
+
151
+// ── 台账同步 ──────────────────────────────────────────
152
+async function handleSync() {
153
+  syncing.value = true
154
+  try {
155
+    const r = await syncLedgerAll()
156
+    const d = r.data || {}
157
+    ElMessage.success(`同步完成:新增 ${d.inserted ?? 0} 条,跳过 ${d.skipped ?? 0} 条`)
158
+    if (selectedTeam.value) loadData()
159
+  } catch (e) {
160
+    ElMessage.error('同步失败,请查看后端日志')
161
+  } finally {
162
+    syncing.value = false
163
+  }
164
+}
165
+
166
+// ── 数据加载 ──────────────────────────────────────────
167
+async function loadTeams() {
168
+  const r = await radarTeamList()
169
+  teamList.value = r.data || []
170
+  if (teamList.value.length) {
171
+    selectedTeam.value = teamList.value[0]
172
+    loadData()
173
+  }
174
+}
175
+
176
+async function loadData() {
177
+  if (!selectedTeam.value) return
178
+  loading.value = true
179
+  compareNames.value = []
180
+  // 销毁旧图
181
+  Object.values(chartMap).forEach(c => c && c.dispose())
182
+  Object.keys(chartMap).forEach(k => delete chartMap[k])
183
+  if (compareChart) { compareChart.dispose(); compareChart = null }
184
+
185
+  const r = await radarMemberScores(selectedTeam.value).finally(() => loading.value = false)
186
+  memberList.value = r.data || []
187
+  await nextTick()
188
+  memberList.value.forEach(m => renderMemberChart(m))
189
+}
190
+
191
+// ── ECharts 渲染 ──────────────────────────────────────
192
+function setChartRef(el, name) {
193
+  if (el) cardRefs[name] = el
194
+}
195
+
196
+function buildRadarIndicators(member) {
197
+  return (member.dimensions || []).map(d => ({
198
+    name: d.dimensionName,
199
+    max: Number(d.baseScore) * 1.5  // max = 1.5×基础分,给加分空间
200
+  }))
201
+}
202
+
203
+function renderMemberChart(member) {
204
+  const el = cardRefs[member.personName]
205
+  if (!el) return
206
+  const chart = echarts.init(el)
207
+  chartMap[member.personName] = chart
208
+  const values = (member.dimensions || []).map(d => Number(d.contribution))
209
+  const color = member.totalScore >= 85 ? '#67C23A'
210
+              : member.totalScore < 75  ? '#F56C6C'
211
+              : '#E6A23C'
212
+  chart.setOption(buildRadarOption(buildRadarIndicators(member), [{ name: member.personName, value: values, color }]))
213
+}
214
+
215
+function renderCompareChart() {
216
+  if (!compareChartRef.value) return
217
+  if (!compareChart) compareChart = echarts.init(compareChartRef.value)
218
+  if (!compareNames.value.length || !memberList.value.length) {
219
+    compareChart.clear(); return
220
+  }
221
+  const indicators = buildRadarIndicators(memberList.value[0])
222
+  const COLORS = ['#E6A23C', '#409EFF']
223
+  const series = compareNames.value.map((name, i) => {
224
+    const m = memberList.value.find(x => x.personName === name)
225
+    const values = m ? m.dimensions.map(d => Number(d.contribution)) : []
226
+    return { name, value: values, color: COLORS[i] }
227
+  })
228
+  compareChart.setOption(buildRadarOption(indicators, series))
229
+}
230
+
231
+function buildRadarOption(indicators, series) {
232
+  return {
233
+    backgroundColor: 'transparent',
234
+    radar: {
235
+      indicator: indicators,
236
+      shape: 'polygon',
237
+      splitNumber: 4,
238
+      center: ['50%', '52%'],
239
+      radius: '65%',
240
+      name: { textStyle: { color: '#a0c4ff', fontSize: 10 } },
241
+      splitLine: { lineStyle: { color: 'rgba(100,149,237,0.3)' } },
242
+      splitArea: { areaStyle: { color: ['rgba(100,149,237,0.05)', 'rgba(100,149,237,0.1)'] } },
243
+      axisLine: { lineStyle: { color: 'rgba(100,149,237,0.4)' } }
244
+    },
245
+    series: [{
246
+      type: 'radar',
247
+      data: series.map(s => ({
248
+        name: s.name,
249
+        value: s.value,
250
+        lineStyle: { color: s.color, width: 2 },
251
+        itemStyle: { color: s.color },
252
+        areaStyle: { color: s.color, opacity: 0.15 }
253
+      })),
254
+      symbol: 'circle',
255
+      symbolSize: 4
256
+    }],
257
+    tooltip: {
258
+      trigger: 'item',
259
+      backgroundColor: 'rgba(10,30,60,0.9)',
260
+      borderColor: '#1a4a8a',
261
+      textStyle: { color: '#e0f0ff', fontSize: 12 }
262
+    }
263
+  }
264
+}
265
+
266
+// ── 响应式调整 ──────────────────────────────────────────
267
+function resizeAll() {
268
+  Object.values(chartMap).forEach(c => c && c.resize())
269
+  compareChart && compareChart.resize()
270
+}
271
+
272
+onMounted(() => {
273
+  loadTeams()
274
+  window.addEventListener('resize', resizeAll)
275
+})
276
+onBeforeUnmount(() => {
277
+  window.removeEventListener('resize', resizeAll)
278
+  Object.values(chartMap).forEach(c => c && c.dispose())
279
+  compareChart && compareChart.dispose()
280
+})
281
+
282
+watch(compareNames, () => nextTick(renderCompareChart), { deep: true })
283
+</script>
284
+
285
+<style scoped>
286
+/* ── 整体背景 ── */
287
+.radar-screen {
288
+  background: #020c1e;
289
+  min-height: 100vh;
290
+  display: flex;
291
+  flex-direction: column;
292
+  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
293
+  overflow: auto;
294
+}
295
+
296
+/* ── 顶部 ── */
297
+.screen-header {
298
+  display: flex;
299
+  align-items: center;
300
+  justify-content: space-between;
301
+  padding: 10px 20px;
302
+  background: linear-gradient(180deg, #051535 0%, #020c1e 100%);
303
+  border-bottom: 1px solid #1a4a8a;
304
+  flex-shrink: 0;
305
+}
306
+.team-select { width: 160px; }
307
+.team-select :deep(.el-input__wrapper) {
308
+  background: #0a2050;
309
+  border-color: #1a6aff;
310
+  box-shadow: none;
311
+}
312
+.team-select :deep(.el-input__inner) { color: #7ecfff; }
313
+.screen-title {
314
+  font-size: 22px;
315
+  font-weight: bold;
316
+  color: #7ecfff;
317
+  letter-spacing: 4px;
318
+  text-shadow: 0 0 12px #1a6aff;
319
+  border: 2px solid #1a6aff;
320
+  padding: 4px 28px;
321
+  border-radius: 4px;
322
+}
323
+.header-right { width: 160px; display: flex; justify-content: flex-end; align-items: center; }
324
+.sync-btn { border-color: #1a6aff; color: #7ecfff; background: transparent; }
325
+
326
+/* ── 主体 ── */
327
+.screen-body { flex: 1; padding: 12px; }
328
+
329
+/* ── 网格:5列,卡片等宽 ── */
330
+.member-grid {
331
+  display: grid;
332
+  grid-template-columns: repeat(5, 1fr);
333
+  grid-auto-rows: minmax(220px, auto);
334
+  gap: 10px;
335
+}
336
+
337
+/* ── 卡片基础 ── */
338
+.member-card {
339
+  background: #041228;
340
+  border: 1px solid #1a4a8a;
341
+  border-radius: 6px;
342
+  padding: 8px;
343
+  display: flex;
344
+  flex-direction: column;
345
+  position: relative;
346
+  overflow: hidden;
347
+}
348
+.member-card::before {
349
+  content: '';
350
+  position: absolute;
351
+  top: 0; left: 0; right: 0;
352
+  height: 2px;
353
+  background: linear-gradient(90deg, transparent, #1a6aff, transparent);
354
+}
355
+
356
+/* 对比分析卡跨2行 */
357
+.compare-card {
358
+  grid-row: span 2;
359
+}
360
+
361
+/* ── 边框颜色 ── */
362
+.border-green { border-color: #67C23A; }
363
+.border-green::before { background: linear-gradient(90deg, transparent, #67C23A, transparent); }
364
+.border-red   { border-color: #F56C6C; }
365
+.border-red::before   { background: linear-gradient(90deg, transparent, #F56C6C, transparent); }
366
+.border-normal { border-color: #1a4a8a; }
367
+
368
+/* ── 卡片标题 ── */
369
+.card-title-bar {
370
+  font-size: 13px;
371
+  font-weight: bold;
372
+  color: #7ecfff;
373
+  padding-bottom: 4px;
374
+  border-bottom: 1px solid #1a4a8a;
375
+  margin-bottom: 4px;
376
+}
377
+.card-name {
378
+  font-size: 13px;
379
+  font-weight: bold;
380
+  color: #c8e6ff;
381
+  text-align: center;
382
+  padding: 4px 0 2px;
383
+}
384
+.name-green { color: #67C23A; }
385
+.name-red   { color: #F56C6C; }
386
+
387
+/* ── 雷达图容器 ── */
388
+.member-radar {
389
+  flex: 1;
390
+  min-height: 170px;
391
+}
392
+.compare-radar {
393
+  height: 200px;
394
+}
395
+
396
+/* ── 对比选择器 ── */
397
+.compare-selector {
398
+  display: flex;
399
+  align-items: center;
400
+  flex-wrap: wrap;
401
+  gap: 6px;
402
+  padding: 6px 0;
403
+  border-top: 1px solid #1a4a8a;
404
+}
405
+.selector-label { color: #7ecfff; font-size: 12px; }
406
+.compare-tag { cursor: pointer; }
407
+.add-select { width: 100px; }
408
+.add-select :deep(.el-input__wrapper) {
409
+  background: #0a2050;
410
+  border-color: #1a6aff;
411
+  box-shadow: none;
412
+}
413
+.add-select :deep(.el-input__inner) { color: #7ecfff; font-size: 12px; }
414
+
415
+/* ── 对比数据表 ── */
416
+.compare-table {
417
+  width: 100%;
418
+  border-collapse: collapse;
419
+  font-size: 11px;
420
+  margin-top: 4px;
421
+}
422
+.compare-table th, .compare-table td {
423
+  border: 1px solid #1a4a8a;
424
+  padding: 3px 6px;
425
+  color: #a0c4ff;
426
+  text-align: center;
427
+}
428
+.compare-table th { background: #071e40; color: #7ecfff; }
429
+.compare-table .col-person { min-width: 48px; }
430
+.score-green { color: #67C23A; font-weight: bold; }
431
+.score-red   { color: #F56C6C; font-weight: bold; }
432
+</style>