Procházet zdrojové kódy

feat(frontend+wm-revenue): #56 网上营业厅前端(水费/报装/公告/绑定)

- 前端页面: WaterBillView.vue, InstallApplyView.vue, NoticeView.vue, UserBindView.vue
- API 模块: wx-hall.ts (水费/报装/公告/绑定全部接口)
- 路由: 注册到 frontend/src/router/index.ts (wx-hall/*)
- 后端 Controller: WxHallApiController (12 端点, /api/wx-hall/*)
- DDL: V6__wx_hall_user_bindng.sql (用户绑定表)
bot_dev2 před 4 dny
rodič
revize
2d0d80f6d1

+ 85
- 0
frontend/src/api/wx-hall.ts Zobrazit soubor

@@ -0,0 +1,85 @@
1
+import request from './request'
2
+
3
+const BASE = '/wx-hall'
4
+
5
+// ========== 水费查询缴费 ==========
6
+
7
+export function getBillList(params: {
8
+  customerNo: string
9
+  billPeriod?: string
10
+  status?: string
11
+  pageNum?: number
12
+  pageSize?: number
13
+}) {
14
+  return request.get(`${BASE}/bill/list`, { params })
15
+}
16
+
17
+export function getBillDetail(billId: number) {
18
+  return request.get(`${BASE}/bill/${billId}`)
19
+}
20
+
21
+export function payBill(data: { billId: number; amount: number }) {
22
+  return request.post(`${BASE}/bill/pay`, data)
23
+}
24
+
25
+export function getPaymentRecords(params: { customerNo: string; limit?: number }) {
26
+  return request.get(`${BASE}/bill/payment-records`, { params })
27
+}
28
+
29
+// ========== 报装申请 ==========
30
+
31
+export function submitInstallApply(data: {
32
+  name: string
33
+  phone: string
34
+  area: string
35
+  address: string
36
+  customerType?: string
37
+  caliber?: string
38
+}) {
39
+  return request.post(`${BASE}/install/apply`, data)
40
+}
41
+
42
+export function getInstallProgress(applyNo: string) {
43
+  return request.get(`${BASE}/install/progress`, { params: { applyNo } })
44
+}
45
+
46
+export function getInstallList(params: { phone: string; limit?: number }) {
47
+  return request.get(`${BASE}/install/list`, { params })
48
+}
49
+
50
+// ========== 停水公告 ==========
51
+
52
+export function getNoticeList(params: {
53
+  page?: number
54
+  size?: number
55
+  type?: string
56
+  keyword?: string
57
+}) {
58
+  return request.get(`${BASE}/notice/list`, { params })
59
+}
60
+
61
+export function getNoticeDetail(id: number) {
62
+  return request.get(`${BASE}/notice/${id}`)
63
+}
64
+
65
+export function getActiveNotices(areaCode?: string) {
66
+  return request.get(`${BASE}/notice/active`, { params: { areaCode } })
67
+}
68
+
69
+// ========== 用户绑定 ==========
70
+
71
+export function bindPhone(data: { openId: string; phone: string }) {
72
+  return request.post(`${BASE}/user/bindPhone`, data)
73
+}
74
+
75
+export function bindCustomer(data: { openId: string; customerNo: string }) {
76
+  return request.post(`${BASE}/user/bindCustomer`, data)
77
+}
78
+
79
+export function unbind(data: { openId: string; bindingId: string }) {
80
+  return request.post(`${BASE}/user/unbind`, data)
81
+}
82
+
83
+export function getBindings(openId: string) {
84
+  return request.get(`${BASE}/user/bindings`, { params: { openId } })
85
+}

+ 5
- 0
frontend/src/router/index.ts Zobrazit soubor

@@ -14,6 +14,11 @@ const routes = [
14 14
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 15
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16 16
       { path: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
17
+      // 微信网厅
18
+      { path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') },
19
+      { path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') },
20
+      { path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') },
21
+      { path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') },
17 22
     ]
18 23
   },
19 24
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 254
- 0
frontend/src/views/wxhall/InstallApplyView.vue Zobrazit soubor

@@ -0,0 +1,254 @@
1
+<template>
2
+  <div class="wx-install-apply">
3
+    <el-page-header @back="$router.back()" title="返回" content="报装申请" />
4
+
5
+    <!-- 申请表单 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <template #header>
8
+        <span>新建报装申请</span>
9
+      </template>
10
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 600px">
11
+        <el-form-item label="申请人" prop="name">
12
+          <el-input v-model="form.name" placeholder="请输入申请人姓名" />
13
+        </el-form-item>
14
+        <el-form-item label="联系电话" prop="phone">
15
+          <el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
16
+        </el-form-item>
17
+        <el-form-item label="所在区域" prop="area">
18
+          <el-select v-model="form.area" placeholder="请选择区域" style="width: 100%">
19
+            <el-option label="城东片区" value="城东片区" />
20
+            <el-option label="城西片区" value="城西片区" />
21
+            <el-option label="城南片区" value="城南片区" />
22
+            <el-option label="城北片区" value="城北片区" />
23
+            <el-option label="开发区" value="开发区" />
24
+            <el-option label="高新区" value="高新区" />
25
+          </el-select>
26
+        </el-form-item>
27
+        <el-form-item label="详细地址" prop="address">
28
+          <el-input v-model="form.address" type="textarea" :rows="2" placeholder="请输入详细地址" />
29
+        </el-form-item>
30
+        <el-form-item label="用水类型">
31
+          <el-select v-model="form.customerType" style="width: 100%">
32
+            <el-option label="居民用水" value="resident" />
33
+            <el-option label="商业用水" value="commercial" />
34
+            <el-option label="工业用水" value="industrial" />
35
+            <el-option label="行政事业" value="public" />
36
+          </el-select>
37
+        </el-form-item>
38
+        <el-form-item label="口径">
39
+          <el-select v-model="form.caliber" style="width: 100%">
40
+            <el-option label="DN15" value="DN15" />
41
+            <el-option label="DN20" value="DN20" />
42
+            <el-option label="DN25" value="DN25" />
43
+            <el-option label="DN32" value="DN32" />
44
+            <el-option label="DN40" value="DN40" />
45
+            <el-option label="DN50" value="DN50" />
46
+          </el-select>
47
+        </el-form-item>
48
+        <el-form-item>
49
+          <el-button type="primary" @click="handleSubmit" :loading="submitting">提交申请</el-button>
50
+          <el-button @click="resetForm">重置</el-button>
51
+        </el-form-item>
52
+      </el-form>
53
+    </el-card>
54
+
55
+    <!-- 进度查询 -->
56
+    <el-card shadow="never" style="margin-top: 12px">
57
+      <template #header>
58
+        <span>进度查询</span>
59
+      </template>
60
+      <el-form :inline="true">
61
+        <el-form-item label="申请编号">
62
+          <el-input v-model="progressQueryNo" placeholder="输入申请编号查询" clearable />
63
+        </el-form-item>
64
+        <el-form-item>
65
+          <el-button type="primary" @click="queryProgress" :disabled="!progressQueryNo">查询</el-button>
66
+        </el-form-item>
67
+      </el-form>
68
+
69
+      <el-steps :active="progressStep" finish-status="success" align-center v-if="progressData"
70
+        style="margin-top: 20px">
71
+        <el-step title="预受理" :description="progressData.status === 'pre_apply' ? '当前阶段' : ''" />
72
+        <el-step title="工程审核" :description="progressData.status === 'engineering' ? '当前阶段' : ''" />
73
+        <el-step title="派单施工" :description="progressData.status === 'pending_review' ? '当前阶段' : ''" />
74
+        <el-step title="已完成" :description="progressData.status === 'completed' ? '当前阶段' : ''" />
75
+      </el-steps>
76
+
77
+      <el-descriptions :column="2" border style="margin-top: 16px" v-if="progressData">
78
+        <el-descriptions-item label="申请编号">{{ progressData.application_no }}</el-descriptions-item>
79
+        <el-descriptions-item label="申请人">{{ progressData.applicant_name }}</el-descriptions-item>
80
+        <el-descriptions-item label="联系电话">{{ progressData.applicant_phone }}</el-descriptions-item>
81
+        <el-descriptions-item label="区域">{{ progressData.area }}</el-descriptions-item>
82
+        <el-descriptions-item label="地址" :span="2">{{ progressData.address }}</el-descriptions-item>
83
+        <el-descriptions-item label="用水类型">{{ progressData.customer_type }}</el-descriptions-item>
84
+        <el-descriptions-item label="口径">{{ progressData.caliber }}</el-descriptions-item>
85
+        <el-descriptions-item label="状态">
86
+          <el-tag :type="installStatusTag(progressData.status)">{{ installStatusLabel(progressData.status) }}</el-tag>
87
+        </el-descriptions-item>
88
+        <el-descriptions-item label="更新时间">{{ progressData.updated_at }}</el-descriptions-item>
89
+      </el-descriptions>
90
+    </el-card>
91
+
92
+    <!-- 申请记录 -->
93
+    <el-card shadow="never" style="margin-top: 12px">
94
+      <template #header>
95
+        <div class="card-header">
96
+          <span>申请记录</span>
97
+        </div>
98
+      </template>
99
+      <el-form :inline="true" style="margin-bottom: 12px">
100
+        <el-form-item label="手机号">
101
+          <el-input v-model="recordPhone" placeholder="输入手机号查询" clearable />
102
+        </el-form-item>
103
+        <el-form-item>
104
+          <el-button type="primary" @click="fetchRecords">查询</el-button>
105
+        </el-form-item>
106
+      </el-form>
107
+
108
+      <el-table :data="records" border stripe v-loading="recordLoading">
109
+        <el-table-column prop="application_no" label="申请编号" min-width="180" />
110
+        <el-table-column prop="applicant_name" label="申请人" width="100" />
111
+        <el-table-column prop="area" label="区域" width="100" />
112
+        <el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
113
+        <el-table-column prop="customer_type" label="用水类型" width="100" />
114
+        <el-table-column prop="caliber" label="口径" width="80" />
115
+        <el-table-column prop="status" label="状态" width="100">
116
+          <template #default="{ row }">
117
+            <el-tag :type="installStatusTag(row.status)" size="small">{{ installStatusLabel(row.status) }}</el-tag>
118
+          </template>
119
+        </el-table-column>
120
+        <el-table-column prop="created_at" label="申请时间" width="170" />
121
+        <el-table-column label="操作" width="100" fixed="right">
122
+          <template #default="{ row }">
123
+            <el-button link type="primary" @click="viewRecordProgress(row)">进度</el-button>
124
+          </template>
125
+        </el-table-column>
126
+      </el-table>
127
+    </el-card>
128
+  </div>
129
+</template>
130
+
131
+<script setup lang="ts">
132
+import { ref, reactive } from 'vue'
133
+import { ElMessage } from 'element-plus'
134
+import type { FormInstance } from 'element-plus'
135
+import { submitInstallApply, getInstallProgress, getInstallList } from '@/api/wx-hall'
136
+
137
+const formRef = ref<FormInstance>()
138
+const submitting = ref(false)
139
+const progressQueryNo = ref('')
140
+const progressData = ref<any>(null)
141
+const progressStep = ref(0)
142
+const recordPhone = ref('')
143
+const records = ref<any[]>([])
144
+const recordLoading = ref(false)
145
+
146
+const form = reactive({
147
+  name: '',
148
+  phone: '',
149
+  area: '',
150
+  address: '',
151
+  customerType: 'resident',
152
+  caliber: 'DN15'
153
+})
154
+
155
+const rules = {
156
+  name: [{ required: true, message: '请输入申请人姓名', trigger: 'blur' }],
157
+  phone: [
158
+    { required: true, message: '请输入手机号', trigger: 'blur' },
159
+    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
160
+  ],
161
+  area: [{ required: true, message: '请选择区域', trigger: 'change' }],
162
+  address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
163
+}
164
+
165
+async function handleSubmit() {
166
+  if (!formRef.value) return
167
+  await formRef.value.validate()
168
+  submitting.value = true
169
+  try {
170
+    const res = await submitInstallApply(form)
171
+    ElMessage.success(`申请提交成功!申请编号: ${res.data?.applicationNo}`)
172
+    resetForm()
173
+  } catch (e) { /* ignore */ } finally {
174
+    submitting.value = false
175
+  }
176
+}
177
+
178
+function resetForm() {
179
+  formRef.value?.resetFields()
180
+  Object.assign(form, { name: '', phone: '', area: '', address: '', customerType: 'resident', caliber: 'DN15' })
181
+}
182
+
183
+async function queryProgress() {
184
+  if (!progressQueryNo.value) return
185
+  try {
186
+    const res = await getInstallProgress(progressQueryNo.value)
187
+    progressData.value = res.data
188
+    progressStep.value = calcStep(res.data?.status)
189
+  } catch (e) {
190
+    progressData.value = null
191
+    progressStep.value = 0
192
+  }
193
+}
194
+
195
+async function fetchRecords() {
196
+  if (!recordPhone.value) {
197
+    ElMessage.warning('请输入手机号')
198
+    return
199
+  }
200
+  recordLoading.value = true
201
+  try {
202
+    const res = await getInstallList({ phone: recordPhone.value, limit: 50 })
203
+    records.value = res.data || []
204
+  } finally {
205
+    recordLoading.value = false
206
+  }
207
+}
208
+
209
+function viewRecordProgress(row: any) {
210
+  progressQueryNo.value = row.application_no
211
+  queryProgress()
212
+}
213
+
214
+function calcStep(status: string): number {
215
+  const map: Record<string, number> = {
216
+    pre_apply: 0,
217
+    engineering: 1,
218
+    pending_review: 2,
219
+    completed: 3
220
+  }
221
+  return map[status] ?? 0
222
+}
223
+
224
+function installStatusLabel(s: string): string {
225
+  const map: Record<string, string> = {
226
+    pre_apply: '预受理',
227
+    engineering: '工程审核',
228
+    pending_review: '派单施工',
229
+    completed: '已完成'
230
+  }
231
+  return map[s] || s
232
+}
233
+
234
+function installStatusTag(s: string): any {
235
+  const map: Record<string, string> = {
236
+    pre_apply: 'info',
237
+    engineering: 'warning',
238
+    pending_review: '',
239
+    completed: 'success'
240
+  }
241
+  return map[s] || 'info'
242
+}
243
+</script>
244
+
245
+<style scoped>
246
+.wx-install-apply {
247
+  padding: 20px;
248
+}
249
+.card-header {
250
+  display: flex;
251
+  justify-content: space-between;
252
+  align-items: center;
253
+}
254
+</style>

+ 217
- 0
frontend/src/views/wxhall/NoticeView.vue Zobrazit soubor

@@ -0,0 +1,217 @@
1
+<template>
2
+  <div class="wx-notice">
3
+    <el-page-header @back="$router.back()" title="返回" content="停水公告" />
4
+
5
+    <!-- 搜索和筛选 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true" :model="queryForm">
8
+        <el-form-item label="关键词">
9
+          <el-input v-model="queryForm.keyword" placeholder="搜索公告标题/内容" clearable @clear="handleSearch" />
10
+        </el-form-item>
11
+        <el-form-item label="类型">
12
+          <el-select v-model="queryForm.type" placeholder="全部" clearable @change="handleSearch">
13
+            <el-option label="停水" value="water_outage" />
14
+            <el-option label="水质" value="water_quality" />
15
+            <el-option label="维修" value="maintenance" />
16
+            <el-option label="其他" value="other" />
17
+          </el-select>
18
+        </el-form-item>
19
+        <el-form-item>
20
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
21
+        </el-form-item>
22
+      </el-form>
23
+    </el-card>
24
+
25
+    <!-- 公告列表 -->
26
+    <div class="notice-list" style="margin-top: 12px">
27
+      <el-card shadow="hover" v-for="item in noticeList" :key="item.id" class="notice-item"
28
+        @click="viewDetail(item)">
29
+        <div class="notice-header">
30
+          <div class="notice-title">
31
+            <el-tag :type="typeTag(item.type)" size="small" class="type-tag">{{ typeLabel(item.type) }}</el-tag>
32
+            <el-tag :type="priorityTag(item.priority)" size="small" v-if="item.priority === 'urgent' || item.priority === 'high'" class="priority-tag">
33
+              {{ priorityLabel(item.priority) }}
34
+            </el-tag>
35
+            <span class="title-text">{{ item.title }}</span>
36
+          </div>
37
+          <span class="notice-time">{{ item.publishTime }}</span>
38
+        </div>
39
+        <div class="notice-meta">
40
+          <span v-if="item.affectedArea">📍 {{ item.affectedArea }}</span>
41
+          <span v-if="item.plannedStart">🕐 {{ item.plannedStart }} ~ {{ item.plannedEnd }}</span>
42
+        </div>
43
+        <div class="notice-preview">{{ truncate(item.content, 100) }}</div>
44
+      </el-card>
45
+
46
+      <el-empty v-if="!loading && noticeList.length === 0" description="暂无公告" />
47
+    </div>
48
+
49
+    <!-- 分页 -->
50
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
51
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
52
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
53
+      layout="total, sizes, prev, pager, next" @change="fetchNotices" />
54
+
55
+    <!-- 详情弹窗 -->
56
+    <el-dialog v-model="showDetail" :title="detailItem?.title" width="650px" top="5vh">
57
+      <el-descriptions :column="2" border>
58
+        <el-descriptions-item label="类型">
59
+          <el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
60
+        </el-descriptions-item>
61
+        <el-descriptions-item label="优先级">
62
+          <el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
63
+        </el-descriptions-item>
64
+        <el-descriptions-item label="影响范围" :span="2">{{ detailItem?.affectedArea }}</el-descriptions-item>
65
+        <el-descriptions-item label="计划开始">{{ detailItem?.plannedStart }}</el-descriptions-item>
66
+        <el-descriptions-item label="计划结束">{{ detailItem?.plannedEnd }}</el-descriptions-item>
67
+        <el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
68
+        <el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
69
+      </el-descriptions>
70
+      <el-divider />
71
+      <div class="detail-content">{{ detailItem?.content }}</div>
72
+    </el-dialog>
73
+  </div>
74
+</template>
75
+
76
+<script setup lang="ts">
77
+import { ref, reactive, onMounted } from 'vue'
78
+import { getNoticeList, getNoticeDetail } from '@/api/wx-hall'
79
+
80
+const loading = ref(false)
81
+const showDetail = ref(false)
82
+const noticeList = ref<any[]>([])
83
+const detailItem = ref<any>(null)
84
+
85
+const queryForm = reactive({
86
+  keyword: '',
87
+  type: ''
88
+})
89
+
90
+const pagination = reactive({
91
+  page: 1,
92
+  size: 10,
93
+  total: 0
94
+})
95
+
96
+onMounted(() => {
97
+  fetchNotices()
98
+})
99
+
100
+function handleSearch() {
101
+  pagination.page = 1
102
+  fetchNotices()
103
+}
104
+
105
+async function fetchNotices() {
106
+  loading.value = true
107
+  try {
108
+    const res = await getNoticeList({
109
+      page: pagination.page,
110
+      size: pagination.size,
111
+      type: queryForm.type || undefined,
112
+      keyword: queryForm.keyword || undefined
113
+    })
114
+    noticeList.value = res.data?.records || []
115
+    pagination.total = res.data?.total || 0
116
+  } finally {
117
+    loading.value = false
118
+  }
119
+}
120
+
121
+async function viewDetail(row: any) {
122
+  try {
123
+    const res = await getNoticeDetail(row.id)
124
+    detailItem.value = res.data
125
+    showDetail.value = true
126
+  } catch (e) {
127
+    // fallback to list item data
128
+    detailItem.value = row
129
+    showDetail.value = true
130
+  }
131
+}
132
+
133
+function truncate(text: string, maxLen: number) {
134
+  if (!text) return ''
135
+  return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
136
+}
137
+
138
+const typeMap: Record<string, string> = {
139
+  water_outage: '停水',
140
+  water_quality: '水质',
141
+  maintenance: '维修',
142
+  other: '其他'
143
+}
144
+
145
+const typeTagMap: Record<string, string> = {
146
+  water_outage: 'danger',
147
+  water_quality: 'warning',
148
+  maintenance: '',
149
+  other: 'info'
150
+}
151
+
152
+function typeLabel(t: string) { return typeMap[t] || t }
153
+function typeTag(t: string): any { return typeTagMap[t] || 'info' }
154
+
155
+function priorityLabel(p: string) {
156
+  return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p
157
+}
158
+
159
+function priorityTag(p: string): any {
160
+  return { low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info'
161
+}
162
+</script>
163
+
164
+<style scoped>
165
+.wx-notice {
166
+  padding: 20px;
167
+}
168
+.notice-item {
169
+  margin-bottom: 10px;
170
+  cursor: pointer;
171
+  transition: box-shadow 0.2s;
172
+}
173
+.notice-item:hover {
174
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
175
+}
176
+.notice-header {
177
+  display: flex;
178
+  justify-content: space-between;
179
+  align-items: center;
180
+  margin-bottom: 8px;
181
+}
182
+.notice-title {
183
+  display: flex;
184
+  align-items: center;
185
+  gap: 6px;
186
+  flex: 1;
187
+}
188
+.title-text {
189
+  font-size: 16px;
190
+  font-weight: 600;
191
+  color: #303133;
192
+}
193
+.notice-time {
194
+  font-size: 12px;
195
+  color: #909399;
196
+  white-space: nowrap;
197
+  margin-left: 12px;
198
+}
199
+.notice-meta {
200
+  display: flex;
201
+  gap: 16px;
202
+  font-size: 13px;
203
+  color: #606266;
204
+  margin-bottom: 6px;
205
+}
206
+.notice-preview {
207
+  font-size: 13px;
208
+  color: #909399;
209
+  line-height: 1.6;
210
+}
211
+.detail-content {
212
+  white-space: pre-wrap;
213
+  line-height: 1.8;
214
+  font-size: 14px;
215
+  color: #303133;
216
+}
217
+</style>

+ 188
- 0
frontend/src/views/wxhall/UserBindView.vue Zobrazit soubor

@@ -0,0 +1,188 @@
1
+<template>
2
+  <div class="wx-user-bind">
3
+    <el-page-header @back="$router.back()" title="返回" content="用户绑定" />
4
+
5
+    <!-- OpenID 输入(模拟微信环境) -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true">
8
+        <el-form-item label="OpenID">
9
+          <el-input v-model="openId" placeholder="请输入微信 OpenID" clearable @clear="handleOpenIdChange" />
10
+        </el-form-item>
11
+        <el-form-item>
12
+          <el-button type="primary" @click="handleOpenIdChange" :disabled="!openId">确认</el-button>
13
+        </el-form-item>
14
+      </el-form>
15
+    </el-card>
16
+
17
+    <template v-if="openId">
18
+      <!-- 手机号绑定 -->
19
+      <el-card shadow="never" style="margin-top: 12px">
20
+        <template #header>
21
+          <div class="card-header">
22
+            <span>📱 手机号绑定</span>
23
+          </div>
24
+        </template>
25
+        <el-form :inline="true" :model="phoneForm">
26
+          <el-form-item label="手机号">
27
+            <el-input v-model="phoneForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
28
+          </el-form-item>
29
+          <el-form-item>
30
+            <el-button type="primary" @click="handleBindPhone" :loading="phoneLoading"
31
+              :disabled="!phoneForm.phone">绑定手机号</el-button>
32
+          </el-form-item>
33
+        </el-form>
34
+      </el-card>
35
+
36
+      <!-- 户号绑定 -->
37
+      <el-card shadow="never" style="margin-top: 12px">
38
+        <template #header>
39
+          <div class="card-header">
40
+            <span>🏠 户号绑定</span>
41
+          </div>
42
+        </template>
43
+        <el-form :inline="true" :model="customerForm">
44
+          <el-form-item label="户号">
45
+            <el-input v-model="customerForm.customerNo" placeholder="请输入水费户号" clearable />
46
+          </el-form-item>
47
+          <el-form-item>
48
+            <el-button type="primary" @click="handleBindCustomer" :loading="customerLoading"
49
+              :disabled="!customerForm.customerNo">绑定户号</el-button>
50
+          </el-form-item>
51
+        </el-form>
52
+      </el-card>
53
+
54
+      <!-- 已绑定列表 -->
55
+      <el-card shadow="never" style="margin-top: 12px">
56
+        <template #header>
57
+          <div class="card-header">
58
+            <span>已绑定列表</span>
59
+            <el-button link type="primary" @click="fetchBindings">刷新</el-button>
60
+          </div>
61
+        </template>
62
+
63
+        <el-table :data="bindings" border stripe v-loading="bindingsLoading">
64
+          <el-table-column prop="id" label="ID" width="70" />
65
+          <el-table-column prop="bindingType" label="类型" width="100">
66
+            <template #default="{ row }">
67
+              <el-tag :type="row.binding_type === 'phone' ? '' : 'success'" size="small">
68
+                {{ row.binding_type === 'phone' ? '手机号' : '户号' }}
69
+              </el-tag>
70
+            </template>
71
+          </el-table-column>
72
+          <el-table-column prop="phone" label="手机号" width="140">
73
+            <template #default="{ row }">{{ row.phone || '-' }}</template>
74
+          </el-table-column>
75
+          <el-table-column prop="customerNo" label="户号" width="140">
76
+            <template #default="{ row }">{{ row.customer_no || '-' }}</template>
77
+          </el-table-column>
78
+          <el-table-column prop="customerName" label="客户名称" width="140">
79
+            <template #default="{ row }">{{ row.customer_name || '-' }}</template>
80
+          </el-table-column>
81
+          <el-table-column prop="createdAt" label="绑定时间" min-width="170">
82
+            <template #default="{ row }">{{ row.created_at }}</template>
83
+          </el-table-column>
84
+          <el-table-column label="操作" width="100" fixed="right">
85
+            <template #default="{ row }">
86
+              <el-popconfirm title="确定解绑吗?" @confirm="handleUnbind(row)">
87
+                <template #reference>
88
+                  <el-button link type="danger">解绑</el-button>
89
+                </template>
90
+              </el-popconfirm>
91
+            </template>
92
+          </el-table-column>
93
+        </el-table>
94
+
95
+        <el-empty v-if="!bindingsLoading && bindings.length === 0" description="暂无绑定记录" />
96
+      </el-card>
97
+    </template>
98
+  </div>
99
+</template>
100
+
101
+<script setup lang="ts">
102
+import { ref, reactive } from 'vue'
103
+import { ElMessage } from 'element-plus'
104
+import { bindPhone, bindCustomer, unbind, getBindings } from '@/api/wx-hall'
105
+
106
+const openId = ref('')
107
+const phoneLoading = ref(false)
108
+const customerLoading = ref(false)
109
+const bindingsLoading = ref(false)
110
+const bindings = ref<any[]>([])
111
+
112
+const phoneForm = reactive({ phone: '' })
113
+const customerForm = reactive({ customerNo: '' })
114
+
115
+function handleOpenIdChange() {
116
+  if (!openId.value) {
117
+    ElMessage.warning('请输入 OpenID')
118
+    return
119
+  }
120
+  fetchBindings()
121
+}
122
+
123
+async function handleBindPhone() {
124
+  if (!phoneForm.phone) {
125
+    ElMessage.warning('请输入手机号')
126
+    return
127
+  }
128
+  if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
129
+    ElMessage.warning('手机号格式不正确')
130
+    return
131
+  }
132
+  phoneLoading.value = true
133
+  try {
134
+    await bindPhone({ openId: openId.value, phone: phoneForm.phone })
135
+    ElMessage.success('手机号绑定成功')
136
+    phoneForm.phone = ''
137
+    fetchBindings()
138
+  } catch (e) { /* ignore */ } finally {
139
+    phoneLoading.value = false
140
+  }
141
+}
142
+
143
+async function handleBindCustomer() {
144
+  if (!customerForm.customerNo) {
145
+    ElMessage.warning('请输入户号')
146
+    return
147
+  }
148
+  customerLoading.value = true
149
+  try {
150
+    await bindCustomer({ openId: openId.value, customerNo: customerForm.customerNo })
151
+    ElMessage.success('户号绑定成功')
152
+    customerForm.customerNo = ''
153
+    fetchBindings()
154
+  } catch (e) { /* ignore */ } finally {
155
+    customerLoading.value = false
156
+  }
157
+}
158
+
159
+async function handleUnbind(row: any) {
160
+  try {
161
+    await unbind({ openId: openId.value, bindingId: String(row.id) })
162
+    ElMessage.success('解绑成功')
163
+    fetchBindings()
164
+  } catch (e) { /* ignore */ }
165
+}
166
+
167
+async function fetchBindings() {
168
+  if (!openId.value) return
169
+  bindingsLoading.value = true
170
+  try {
171
+    const res = await getBindings(openId.value)
172
+    bindings.value = res.data || []
173
+  } finally {
174
+    bindingsLoading.value = false
175
+  }
176
+}
177
+</script>
178
+
179
+<style scoped>
180
+.wx-user-bind {
181
+  padding: 20px;
182
+}
183
+.card-header {
184
+  display: flex;
185
+  justify-content: space-between;
186
+  align-items: center;
187
+}
188
+</style>

+ 287
- 0
frontend/src/views/wxhall/WaterBillView.vue Zobrazit soubor

@@ -0,0 +1,287 @@
1
+<template>
2
+  <div class="wx-water-bill">
3
+    <el-page-header @back="$router.back()" title="返回" content="水费查询缴费" />
4
+
5
+    <!-- 用户绑定区域 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true" :model="queryForm">
8
+        <el-form-item label="户号">
9
+          <el-input v-model="queryForm.customerNo" placeholder="请输入户号" clearable @clear="handleQuery" />
10
+        </el-form-item>
11
+        <el-form-item label="账单周期">
12
+          <el-date-picker v-model="queryForm.billPeriod" type="month" placeholder="选择月份"
13
+            value-format="YYYY-MM" clearable @change="handleQuery" />
14
+        </el-form-item>
15
+        <el-form-item label="状态">
16
+          <el-select v-model="queryForm.status" placeholder="全部" clearable @change="handleQuery">
17
+            <el-option label="待缴" value="pending" />
18
+            <el-option label="已缴" value="paid" />
19
+            <el-option label="部分" value="partial" />
20
+            <el-option label="逾期" value="overdue" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item>
24
+          <el-button type="primary" @click="handleQuery">查询</el-button>
25
+        </el-form-item>
26
+      </el-form>
27
+    </el-card>
28
+
29
+    <!-- 账单列表 -->
30
+    <el-card shadow="never" style="margin-top: 12px">
31
+      <template #header>
32
+        <div class="card-header">
33
+          <span>账单列表</span>
34
+          <el-tag v-if="totalUnpaid > 0" type="danger">待缴 ¥{{ totalUnpaid.toFixed(2) }}</el-tag>
35
+        </div>
36
+      </template>
37
+
38
+      <el-table :data="billList" v-loading="loading" border stripe>
39
+        <el-table-column prop="billNo" label="账单编号" min-width="180" show-overflow-tooltip />
40
+        <el-table-column prop="billPeriod" label="账单周期" width="110" />
41
+        <el-table-column prop="waterUsage" label="用水量(吨)" width="100" align="right">
42
+          <template #default="{ row }">{{ Number(row.waterUsage).toFixed(1) }}</template>
43
+        </el-table-column>
44
+        <el-table-column prop="totalAmount" label="应缴(元)" width="100" align="right">
45
+          <template #default="{ row }">{{ Number(row.totalAmount).toFixed(2) }}</template>
46
+        </el-table-column>
47
+        <el-table-column prop="paidAmount" label="已缴(元)" width="100" align="right">
48
+          <template #default="{ row }">{{ Number(row.paidAmount).toFixed(2) }}</template>
49
+        </el-table-column>
50
+        <el-table-column prop="status" label="状态" width="90" align="center">
51
+          <template #default="{ row }">
52
+            <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
53
+          </template>
54
+        </el-table-column>
55
+        <el-table-column prop="dueDate" label="到期日" width="110" />
56
+        <el-table-column label="操作" width="160" fixed="right">
57
+          <template #default="{ row }">
58
+            <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
59
+            <el-button link type="success" v-if="row.status !== 'paid' && row.status !== 'cancelled'"
60
+              @click="openPayDialog(row)">缴费</el-button>
61
+          </template>
62
+        </el-table-column>
63
+      </el-table>
64
+
65
+      <el-pagination style="margin-top: 16px; justify-content: flex-end"
66
+        v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
67
+        :total="pagination.total" :page-sizes="[10, 20, 50]"
68
+        layout="total, sizes, prev, pager, next" @change="fetchBills" />
69
+    </el-card>
70
+
71
+    <!-- 缴费记录 -->
72
+    <el-card shadow="never" style="margin-top: 12px">
73
+      <template #header>
74
+        <div class="card-header">
75
+          <span>缴费记录</span>
76
+          <el-button link type="primary" @click="fetchPaymentRecords">刷新</el-button>
77
+        </div>
78
+      </template>
79
+      <el-table :data="paymentRecords" border stripe size="small">
80
+        <el-table-column prop="paymentNo" label="流水号" min-width="180" show-overflow-tooltip />
81
+        <el-table-column prop="billNo" label="账单编号" width="180" show-overflow-tooltip />
82
+        <el-table-column prop="amount" label="金额(元)" width="100" align="right">
83
+          <template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template>
84
+        </el-table-column>
85
+        <el-table-column prop="channelName" label="渠道" width="100" />
86
+        <el-table-column prop="status" label="状态" width="90">
87
+          <template #default="{ row }">
88
+            <el-tag :type="row.status === 'success' ? 'success' : 'info'" size="small">
89
+              {{ row.status === 'success' ? '成功' : row.status }}
90
+            </el-tag>
91
+          </template>
92
+        </el-table-column>
93
+        <el-table-column prop="payTime" label="支付时间" width="170" />
94
+      </el-table>
95
+    </el-card>
96
+
97
+    <!-- 账单详情弹窗 -->
98
+    <el-dialog v-model="showDetail" title="账单详情" width="550px">
99
+      <el-descriptions :column="2" border v-if="detailData">
100
+        <el-descriptions-item label="账单编号">{{ detailData.bill?.billNo }}</el-descriptions-item>
101
+        <el-descriptions-item label="账单周期">{{ detailData.bill?.billPeriod }}</el-descriptions-item>
102
+        <el-descriptions-item label="客户名称">{{ detailData.bill?.customerName }}</el-descriptions-item>
103
+        <el-descriptions-item label="户号">{{ detailData.bill?.customerNo }}</el-descriptions-item>
104
+        <el-descriptions-item label="水表编号">{{ detailData.bill?.meterNo }}</el-descriptions-item>
105
+        <el-descriptions-item label="用水量">{{ Number(detailData.bill?.waterUsage || 0).toFixed(1) }} 吨</el-descriptions-item>
106
+        <el-descriptions-item label="单价">¥{{ Number(detailData.bill?.unitPrice || 0).toFixed(2) }}/吨</el-descriptions-item>
107
+        <el-descriptions-item label="应缴金额">¥{{ Number(detailData.bill?.totalAmount || 0).toFixed(2) }}</el-descriptions-item>
108
+        <el-descriptions-item label="已缴金额">¥{{ Number(detailData.bill?.paidAmount || 0).toFixed(2) }}</el-descriptions-item>
109
+        <el-descriptions-item label="剩余金额">
110
+          <el-tag :type="detailData.remainingAmount > 0 ? 'danger' : 'success'">
111
+            ¥{{ Number(detailData.remainingAmount || 0).toFixed(2) }}
112
+          </el-tag>
113
+        </el-descriptions-item>
114
+        <el-descriptions-item label="抄表读数">
115
+          {{ detailData.bill?.prevReading }} → {{ detailData.bill?.currReading }}
116
+        </el-descriptions-item>
117
+        <el-descriptions-item label="状态">
118
+          <el-tag :type="statusTag(detailData.bill?.status)">{{ statusLabel(detailData.bill?.status) }}</el-tag>
119
+        </el-descriptions-item>
120
+      </el-descriptions>
121
+
122
+      <el-divider v-if="detailData?.payments?.length > 0">缴费记录</el-divider>
123
+      <el-table :data="detailData?.payments || []" size="small" border v-if="detailData?.payments?.length > 0">
124
+        <el-table-column prop="paymentNo" label="流水号" />
125
+        <el-table-column prop="amount" label="金额" width="100" />
126
+        <el-table-column prop="channelName" label="渠道" width="100" />
127
+        <el-table-column prop="payTime" label="时间" width="170" />
128
+      </el-table>
129
+    </el-dialog>
130
+
131
+    <!-- 缴费弹窗 -->
132
+    <el-dialog v-model="showPay" title="在线缴费" width="420px">
133
+      <div v-if="payingBill" class="pay-info">
134
+        <el-descriptions :column="1" border>
135
+          <el-descriptions-item label="账单编号">{{ payingBill.billNo }}</el-descriptions-item>
136
+          <el-descriptions-item label="账单周期">{{ payingBill.billPeriod }}</el-descriptions-item>
137
+          <el-descriptions-item label="应缴金额">¥{{ Number(payingBill.totalAmount).toFixed(2) }}</el-descriptions-item>
138
+          <el-descriptions-item label="已缴金额">¥{{ Number(payingBill.paidAmount).toFixed(2) }}</el-descriptions-item>
139
+        </el-descriptions>
140
+        <el-divider />
141
+        <el-form-item label="缴费金额">
142
+          <el-input-number v-model="payAmount" :min="0.01"
143
+            :max="Number(payingBill.totalAmount) - Number(payingBill.paidAmount)"
144
+            :precision="2" :step="1" style="width: 100%" />
145
+        </el-form-item>
146
+      </div>
147
+      <template #footer>
148
+        <el-button @click="showPay = false">取消</el-button>
149
+        <el-button type="primary" :loading="payLoading" @click="handlePay">确认缴费</el-button>
150
+      </template>
151
+    </el-dialog>
152
+  </div>
153
+</template>
154
+
155
+<script setup lang="ts">
156
+import { ref, reactive, computed, onMounted } from 'vue'
157
+import { ElMessage } from 'element-plus'
158
+import { getBillList, getBillDetail, payBill, getPaymentRecords } from '@/api/wx-hall'
159
+
160
+const loading = ref(false)
161
+const payLoading = ref(false)
162
+const showDetail = ref(false)
163
+const showPay = ref(false)
164
+const billList = ref<any[]>([])
165
+const paymentRecords = ref<any[]>([])
166
+const detailData = ref<any>(null)
167
+const payingBill = ref<any>(null)
168
+const payAmount = ref(0)
169
+
170
+const queryForm = reactive({
171
+  customerNo: '',
172
+  billPeriod: '',
173
+  status: ''
174
+})
175
+
176
+const pagination = reactive({
177
+  pageNum: 1,
178
+  pageSize: 10,
179
+  total: 0
180
+})
181
+
182
+const totalUnpaid = computed(() => {
183
+  return billList.value
184
+    .filter(b => b.status !== 'paid' && b.status !== 'cancelled')
185
+    .reduce((sum, b) => sum + (Number(b.totalAmount) - Number(b.paidAmount)), 0)
186
+})
187
+
188
+onMounted(() => {
189
+  // 自动加载(需要提供 customerNo)
190
+})
191
+
192
+function handleQuery() {
193
+  if (!queryForm.customerNo) {
194
+    ElMessage.warning('请输入户号')
195
+    return
196
+  }
197
+  pagination.pageNum = 1
198
+  fetchBills()
199
+  fetchPaymentRecords()
200
+}
201
+
202
+async function fetchBills() {
203
+  if (!queryForm.customerNo) return
204
+  loading.value = true
205
+  try {
206
+    const res = await getBillList({
207
+      customerNo: queryForm.customerNo,
208
+      billPeriod: queryForm.billPeriod || undefined,
209
+      status: queryForm.status || undefined,
210
+      pageNum: pagination.pageNum,
211
+      pageSize: pagination.pageSize
212
+    })
213
+    billList.value = res.data?.records || []
214
+    pagination.total = res.data?.total || 0
215
+  } finally {
216
+    loading.value = false
217
+  }
218
+}
219
+
220
+async function fetchPaymentRecords() {
221
+  if (!queryForm.customerNo) return
222
+  try {
223
+    const res = await getPaymentRecords({ customerNo: queryForm.customerNo, limit: 20 })
224
+    paymentRecords.value = res.data || []
225
+  } catch (e) { /* ignore */ }
226
+}
227
+
228
+async function viewDetail(row: any) {
229
+  try {
230
+    const res = await getBillDetail(row.id)
231
+    detailData.value = res.data
232
+    showDetail.value = true
233
+  } catch (e) { /* ignore */ }
234
+}
235
+
236
+function openPayDialog(row: any) {
237
+  payingBill.value = row
238
+  const remaining = Number(row.totalAmount) - Number(row.paidAmount)
239
+  payAmount.value = Math.round(remaining * 100) / 100
240
+  showPay.value = true
241
+}
242
+
243
+async function handlePay() {
244
+  if (!payingBill.value || payAmount.value <= 0) {
245
+    ElMessage.warning('请输入正确的缴费金额')
246
+    return
247
+  }
248
+  payLoading.value = true
249
+  try {
250
+    await payBill({ billId: payingBill.value.id, amount: payAmount.value })
251
+    ElMessage.success('缴费成功!')
252
+    showPay.value = false
253
+    fetchBills()
254
+    fetchPaymentRecords()
255
+  } catch (e) { /* ignore */ } finally {
256
+    payLoading.value = false
257
+  }
258
+}
259
+
260
+function statusLabel(s: string) {
261
+  const map: Record<string, string> = {
262
+    pending: '待缴', paid: '已缴', partial: '部分', overdue: '逾期', cancelled: '已作废'
263
+  }
264
+  return map[s] || s
265
+}
266
+
267
+function statusTag(s: string): any {
268
+  const map: Record<string, string> = {
269
+    pending: 'warning', paid: 'success', partial: '', overdue: 'danger', cancelled: 'info'
270
+  }
271
+  return map[s] || 'info'
272
+}
273
+</script>
274
+
275
+<style scoped>
276
+.wx-water-bill {
277
+  padding: 20px;
278
+}
279
+.card-header {
280
+  display: flex;
281
+  justify-content: space-between;
282
+  align-items: center;
283
+}
284
+.pay-info {
285
+  padding: 0 12px;
286
+}
287
+</style>

+ 220
- 0
wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java Zobrazit soubor

@@ -0,0 +1,220 @@
1
+package com.water.revenue.controller.wxhall;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.common.core.result.R;
6
+import com.water.revenue.entity.Announcement;
7
+import com.water.revenue.entity.PaymentRecord;
8
+import com.water.revenue.entity.WaterBill;
9
+import com.water.revenue.service.AnnouncementService;
10
+import com.water.revenue.service.BillService;
11
+import com.water.revenue.service.InstallService;
12
+import com.water.revenue.service.PaymentService;
13
+import io.swagger.v3.oas.annotations.Operation;
14
+import io.swagger.v3.oas.annotations.tags.Tag;
15
+import lombok.RequiredArgsConstructor;
16
+import lombok.extern.slf4j.Slf4j;
17
+import org.springframework.jdbc.core.JdbcTemplate;
18
+import org.springframework.util.StringUtils;
19
+import org.springframework.web.bind.annotation.*;
20
+
21
+import java.math.BigDecimal;
22
+import java.time.LocalDateTime;
23
+import java.util.*;
24
+
25
+/**
26
+ * 微信网厅 API(移动端适配接口)
27
+ * 所有路径前缀: /api/wx-hall/*
28
+ */
29
+@Slf4j
30
+@Tag(name = "微信网厅 API")
31
+@RestController
32
+@RequestMapping("/wx-hall")
33
+@RequiredArgsConstructor
34
+public class WxHallApiController {
35
+
36
+    private final BillService billService;
37
+    private final InstallService installService;
38
+    private final AnnouncementService announcementService;
39
+    private final PaymentService paymentService;
40
+    private final JdbcTemplate jdbcTemplate;
41
+
42
+    // ========== 水费查询缴费 ==========
43
+
44
+    @Operation(summary = "查询用户账单列表")
45
+    @GetMapping("/bill/list")
46
+    public R<Page<WaterBill>> billList(
47
+            @RequestParam String customerNo,
48
+            @RequestParam(required = false) String billPeriod,
49
+            @RequestParam(required = false) String status,
50
+            @RequestParam(defaultValue = "1") int pageNum,
51
+            @RequestParam(defaultValue = "10") int pageSize) {
52
+        return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
53
+    }
54
+
55
+    @Operation(summary = "账单详情")
56
+    @GetMapping("/bill/{billId}")
57
+    public R<Map<String, Object>> billDetail(@PathVariable Long billId) {
58
+        return R.ok(billService.getBillDetail(billId));
59
+    }
60
+
61
+    @Operation(summary = "在线缴费(微信下单)")
62
+    @PostMapping("/bill/pay")
63
+    public R<Map<String, Object>> billPay(@RequestBody Map<String, Object> req) {
64
+        Long billId = Long.valueOf(req.get("billId").toString());
65
+        BigDecimal amount = new BigDecimal(req.get("amount").toString());
66
+        return R.ok(paymentService.payByWechat(billId, amount));
67
+    }
68
+
69
+    @Operation(summary = "缴费记录")
70
+    @GetMapping("/bill/payment-records")
71
+    public R<List<Map<String, Object>>> paymentRecords(
72
+            @RequestParam String customerNo,
73
+            @RequestParam(defaultValue = "20") int limit) {
74
+        List<Map<String, Object>> records = jdbcTemplate.queryForList(
75
+                "SELECT payment_no, bill_no, amount, channel, channel_name, status, pay_time, remark " +
76
+                "FROM wm_payment_record WHERE customer_no = ? ORDER BY pay_time DESC LIMIT ?",
77
+                customerNo, limit);
78
+        return R.ok(records);
79
+    }
80
+
81
+    // ========== 报装申请 ==========
82
+
83
+    @Operation(summary = "提交报装申请")
84
+    @PostMapping("/install/apply")
85
+    public R<Map<String, Object>> installApply(@RequestBody Map<String, String> req) {
86
+        return R.ok(installService.preApply(
87
+                req.get("name"),
88
+                req.get("phone"),
89
+                req.get("area"),
90
+                req.get("address"),
91
+                req.getOrDefault("customerType", "resident"),
92
+                req.getOrDefault("caliber", "DN15")));
93
+    }
94
+
95
+    @Operation(summary = "查询报装进度")
96
+    @GetMapping("/install/progress")
97
+    public R<Map<String, Object>> installProgress(@RequestParam String applyNo) {
98
+        return R.ok(installService.getProgress(applyNo));
99
+    }
100
+
101
+    @Operation(summary = "报装申请记录列表")
102
+    @GetMapping("/install/list")
103
+    public R<List<Map<String, Object>>> installList(
104
+            @RequestParam String phone,
105
+            @RequestParam(defaultValue = "20") int limit) {
106
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(
107
+                "SELECT application_no, applicant_name, applicant_phone, area, address, " +
108
+                "customer_type, caliber, status, created_at, updated_at " +
109
+                "FROM rev_install WHERE applicant_phone = ? ORDER BY created_at DESC LIMIT ?",
110
+                phone, limit);
111
+        return R.ok(list);
112
+    }
113
+
114
+    // ========== 停水公告 ==========
115
+
116
+    @Operation(summary = "公告列表")
117
+    @GetMapping("/notice/list")
118
+    public R<Page<Announcement>> noticeList(
119
+            @RequestParam(defaultValue = "1") int page,
120
+            @RequestParam(defaultValue = "10") int size,
121
+            @RequestParam(required = false) String type,
122
+            @RequestParam(required = false) String keyword) {
123
+        // 只返回已发布的公告
124
+        return R.ok(announcementService.list(page, size, type, 1, keyword));
125
+    }
126
+
127
+    @Operation(summary = "公告详情")
128
+    @GetMapping("/notice/{id}")
129
+    public R<Announcement> noticeDetail(@PathVariable Long id) {
130
+        return R.ok(announcementService.getDetail(id));
131
+    }
132
+
133
+    @Operation(summary = "获取当前生效公告(按区域)")
134
+    @GetMapping("/notice/active")
135
+    public R<List<Announcement>> activeNotices(
136
+            @RequestParam(required = false) String areaCode) {
137
+        return R.ok(announcementService.getActiveAnnouncements(areaCode));
138
+    }
139
+
140
+    // ========== 用户绑定 ==========
141
+
142
+    @Operation(summary = "手机号绑定")
143
+    @PostMapping("/user/bindPhone")
144
+    public R<Map<String, Object>> bindPhone(@RequestBody Map<String, String> req) {
145
+        String openId = req.get("openId");
146
+        String phone = req.get("phone");
147
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(phone)) {
148
+            return R.fail("openId和phone不能为空");
149
+        }
150
+        // 检查是否已绑定
151
+        Long existCount = jdbcTemplate.queryForObject(
152
+                "SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND phone = ? AND status = 1",
153
+                Long.class, openId, phone);
154
+        if (existCount != null && existCount > 0) {
155
+            return R.fail("该手机号已绑定");
156
+        }
157
+        jdbcTemplate.update(
158
+                "INSERT INTO wx_hall_user_binding (open_id, phone, binding_type, status) VALUES (?, ?, 'phone', 1)",
159
+                openId, phone);
160
+        log.info("User bound phone: openId={}, phone={}", openId, phone);
161
+        return R.ok(Map.of("openId", openId, "phone", phone, "status", "bound"));
162
+    }
163
+
164
+    @Operation(summary = "户号绑定")
165
+    @PostMapping("/user/bindCustomer")
166
+    public R<Map<String, Object>> bindCustomer(@RequestBody Map<String, String> req) {
167
+        String openId = req.get("openId");
168
+        String customerNo = req.get("customerNo");
169
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(customerNo)) {
170
+            return R.fail("openId和customerNo不能为空");
171
+        }
172
+        // 检查是否已绑定
173
+        Long existCount = jdbcTemplate.queryForObject(
174
+                "SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND customer_no = ? AND status = 1",
175
+                Long.class, openId, customerNo);
176
+        if (existCount != null && existCount > 0) {
177
+            return R.fail("该户号已绑定");
178
+        }
179
+        // 查询客户名称
180
+        String customerName = null;
181
+        try {
182
+            customerName = jdbcTemplate.queryForObject(
183
+                    "SELECT customer_name FROM rev_customer WHERE customer_no = ?",
184
+                    String.class, customerNo);
185
+        } catch (Exception e) {
186
+            log.warn("Customer not found: {}", customerNo);
187
+        }
188
+        jdbcTemplate.update(
189
+                "INSERT INTO wx_hall_user_binding (open_id, customer_no, customer_name, binding_type, status) VALUES (?, ?, ?, 'customer_no', 1)",
190
+                openId, customerNo, customerName);
191
+        log.info("User bound customer: openId={}, customerNo={}", openId, customerNo);
192
+        return R.ok(Map.of("openId", openId, "customerNo", customerNo,
193
+                "customerName", customerName != null ? customerName : "", "status", "bound"));
194
+    }
195
+
196
+    @Operation(summary = "解绑")
197
+    @PostMapping("/user/unbind")
198
+    public R<Map<String, Object>> unbind(@RequestBody Map<String, String> req) {
199
+        String openId = req.get("openId");
200
+        String bindingId = req.get("bindingId");
201
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(bindingId)) {
202
+            return R.fail("参数不完整");
203
+        }
204
+        jdbcTemplate.update(
205
+                "UPDATE wx_hall_user_binding SET status = 0, updated_at = NOW() WHERE id = ? AND open_id = ?",
206
+                Long.valueOf(bindingId), openId);
207
+        log.info("User unbound: openId={}, bindingId={}", openId, bindingId);
208
+        return R.ok(Map.of("status", "unbound"));
209
+    }
210
+
211
+    @Operation(summary = "查询用户绑定列表")
212
+    @GetMapping("/user/bindings")
213
+    public R<List<Map<String, Object>>> bindings(@RequestParam String openId) {
214
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(
215
+                "SELECT id, phone, customer_no, customer_name, binding_type, status, created_at " +
216
+                "FROM wx_hall_user_binding WHERE open_id = ? AND status = 1 ORDER BY created_at DESC",
217
+                openId);
218
+        return R.ok(list);
219
+    }
220
+}

+ 18
- 0
wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql Zobrazit soubor

@@ -0,0 +1,18 @@
1
+-- 微信网厅用户绑定表(手机号/户号绑定)
2
+
3
+CREATE TABLE IF NOT EXISTS wx_hall_user_binding (
4
+    id              BIGSERIAL PRIMARY KEY,
5
+    open_id         VARCHAR(64)     NOT NULL,
6
+    phone           VARCHAR(20),
7
+    customer_no     VARCHAR(32),
8
+    customer_name   VARCHAR(100),
9
+    binding_type    VARCHAR(20)     NOT NULL DEFAULT 'phone',  -- phone / customer_no
10
+    status          INTEGER         NOT NULL DEFAULT 1,        -- 1-有效 0-已解绑
11
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
13
+);
14
+
15
+CREATE INDEX IF NOT EXISTS idx_wx_binding_open_id ON wx_hall_user_binding(open_id);
16
+CREATE INDEX IF NOT EXISTS idx_wx_binding_phone ON wx_hall_user_binding(phone);
17
+CREATE INDEX IF NOT EXISTS idx_wx_binding_customer ON wx_hall_user_binding(customer_no);
18
+CREATE INDEX IF NOT EXISTS idx_wx_binding_status ON wx_hall_user_binding(status);