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 (用户绑定表)
This commit is contained in:
2026-06-15 08:42:59 +08:00
parent ba535c47e2
commit 2d0d80f6d1
8 changed files with 1274 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import request from './request'
const BASE = '/wx-hall'
// ========== 水费查询缴费 ==========
export function getBillList(params: {
customerNo: string
billPeriod?: string
status?: string
pageNum?: number
pageSize?: number
}) {
return request.get(`${BASE}/bill/list`, { params })
}
export function getBillDetail(billId: number) {
return request.get(`${BASE}/bill/${billId}`)
}
export function payBill(data: { billId: number; amount: number }) {
return request.post(`${BASE}/bill/pay`, data)
}
export function getPaymentRecords(params: { customerNo: string; limit?: number }) {
return request.get(`${BASE}/bill/payment-records`, { params })
}
// ========== 报装申请 ==========
export function submitInstallApply(data: {
name: string
phone: string
area: string
address: string
customerType?: string
caliber?: string
}) {
return request.post(`${BASE}/install/apply`, data)
}
export function getInstallProgress(applyNo: string) {
return request.get(`${BASE}/install/progress`, { params: { applyNo } })
}
export function getInstallList(params: { phone: string; limit?: number }) {
return request.get(`${BASE}/install/list`, { params })
}
// ========== 停水公告 ==========
export function getNoticeList(params: {
page?: number
size?: number
type?: string
keyword?: string
}) {
return request.get(`${BASE}/notice/list`, { params })
}
export function getNoticeDetail(id: number) {
return request.get(`${BASE}/notice/${id}`)
}
export function getActiveNotices(areaCode?: string) {
return request.get(`${BASE}/notice/active`, { params: { areaCode } })
}
// ========== 用户绑定 ==========
export function bindPhone(data: { openId: string; phone: string }) {
return request.post(`${BASE}/user/bindPhone`, data)
}
export function bindCustomer(data: { openId: string; customerNo: string }) {
return request.post(`${BASE}/user/bindCustomer`, data)
}
export function unbind(data: { openId: string; bindingId: string }) {
return request.post(`${BASE}/user/unbind`, data)
}
export function getBindings(openId: string) {
return request.get(`${BASE}/user/bindings`, { params: { openId } })
}
+5
View File
@@ -14,6 +14,11 @@ const routes = [
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
{ path: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
// 微信网厅
{ path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') },
{ path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') },
{ path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') },
{ path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
@@ -0,0 +1,254 @@
<template>
<div class="wx-install-apply">
<el-page-header @back="$router.back()" title="返回" content="报装申请" />
<!-- 申请表单 -->
<el-card shadow="never" style="margin-top: 16px">
<template #header>
<span>新建报装申请</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 600px">
<el-form-item label="申请人" prop="name">
<el-input v-model="form.name" placeholder="请输入申请人姓名" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
<el-form-item label="所在区域" prop="area">
<el-select v-model="form.area" placeholder="请选择区域" style="width: 100%">
<el-option label="城东片区" value="城东片区" />
<el-option label="城西片区" value="城西片区" />
<el-option label="城南片区" value="城南片区" />
<el-option label="城北片区" value="城北片区" />
<el-option label="开发区" value="开发区" />
<el-option label="高新区" value="高新区" />
</el-select>
</el-form-item>
<el-form-item label="详细地址" prop="address">
<el-input v-model="form.address" type="textarea" :rows="2" placeholder="请输入详细地址" />
</el-form-item>
<el-form-item label="用水类型">
<el-select v-model="form.customerType" style="width: 100%">
<el-option label="居民用水" value="resident" />
<el-option label="商业用水" value="commercial" />
<el-option label="工业用水" value="industrial" />
<el-option label="行政事业" value="public" />
</el-select>
</el-form-item>
<el-form-item label="口径">
<el-select v-model="form.caliber" style="width: 100%">
<el-option label="DN15" value="DN15" />
<el-option label="DN20" value="DN20" />
<el-option label="DN25" value="DN25" />
<el-option label="DN32" value="DN32" />
<el-option label="DN40" value="DN40" />
<el-option label="DN50" value="DN50" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="submitting">提交申请</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 进度查询 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<span>进度查询</span>
</template>
<el-form :inline="true">
<el-form-item label="申请编号">
<el-input v-model="progressQueryNo" placeholder="输入申请编号查询" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="queryProgress" :disabled="!progressQueryNo">查询</el-button>
</el-form-item>
</el-form>
<el-steps :active="progressStep" finish-status="success" align-center v-if="progressData"
style="margin-top: 20px">
<el-step title="预受理" :description="progressData.status === 'pre_apply' ? '当前阶段' : ''" />
<el-step title="工程审核" :description="progressData.status === 'engineering' ? '当前阶段' : ''" />
<el-step title="派单施工" :description="progressData.status === 'pending_review' ? '当前阶段' : ''" />
<el-step title="已完成" :description="progressData.status === 'completed' ? '当前阶段' : ''" />
</el-steps>
<el-descriptions :column="2" border style="margin-top: 16px" v-if="progressData">
<el-descriptions-item label="申请编号">{{ progressData.application_no }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ progressData.applicant_name }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ progressData.applicant_phone }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ progressData.area }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ progressData.address }}</el-descriptions-item>
<el-descriptions-item label="用水类型">{{ progressData.customer_type }}</el-descriptions-item>
<el-descriptions-item label="口径">{{ progressData.caliber }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="installStatusTag(progressData.status)">{{ installStatusLabel(progressData.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ progressData.updated_at }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 申请记录 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>申请记录</span>
</div>
</template>
<el-form :inline="true" style="margin-bottom: 12px">
<el-form-item label="手机号">
<el-input v-model="recordPhone" placeholder="输入手机号查询" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchRecords">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="records" border stripe v-loading="recordLoading">
<el-table-column prop="application_no" label="申请编号" min-width="180" />
<el-table-column prop="applicant_name" label="申请人" width="100" />
<el-table-column prop="area" label="区域" width="100" />
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_type" label="用水类型" width="100" />
<el-table-column prop="caliber" label="口径" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="installStatusTag(row.status)" size="small">{{ installStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewRecordProgress(row)">进度</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { submitInstallApply, getInstallProgress, getInstallList } from '@/api/wx-hall'
const formRef = ref<FormInstance>()
const submitting = ref(false)
const progressQueryNo = ref('')
const progressData = ref<any>(null)
const progressStep = ref(0)
const recordPhone = ref('')
const records = ref<any[]>([])
const recordLoading = ref(false)
const form = reactive({
name: '',
phone: '',
area: '',
address: '',
customerType: 'resident',
caliber: 'DN15'
})
const rules = {
name: [{ required: true, message: '请输入申请人姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
area: [{ required: true, message: '请选择区域', trigger: 'change' }],
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
const res = await submitInstallApply(form)
ElMessage.success(`申请提交成功!申请编号: ${res.data?.applicationNo}`)
resetForm()
} catch (e) { /* ignore */ } finally {
submitting.value = false
}
}
function resetForm() {
formRef.value?.resetFields()
Object.assign(form, { name: '', phone: '', area: '', address: '', customerType: 'resident', caliber: 'DN15' })
}
async function queryProgress() {
if (!progressQueryNo.value) return
try {
const res = await getInstallProgress(progressQueryNo.value)
progressData.value = res.data
progressStep.value = calcStep(res.data?.status)
} catch (e) {
progressData.value = null
progressStep.value = 0
}
}
async function fetchRecords() {
if (!recordPhone.value) {
ElMessage.warning('请输入手机号')
return
}
recordLoading.value = true
try {
const res = await getInstallList({ phone: recordPhone.value, limit: 50 })
records.value = res.data || []
} finally {
recordLoading.value = false
}
}
function viewRecordProgress(row: any) {
progressQueryNo.value = row.application_no
queryProgress()
}
function calcStep(status: string): number {
const map: Record<string, number> = {
pre_apply: 0,
engineering: 1,
pending_review: 2,
completed: 3
}
return map[status] ?? 0
}
function installStatusLabel(s: string): string {
const map: Record<string, string> = {
pre_apply: '预受理',
engineering: '工程审核',
pending_review: '派单施工',
completed: '已完成'
}
return map[s] || s
}
function installStatusTag(s: string): any {
const map: Record<string, string> = {
pre_apply: 'info',
engineering: 'warning',
pending_review: '',
completed: 'success'
}
return map[s] || 'info'
}
</script>
<style scoped>
.wx-install-apply {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div class="wx-notice">
<el-page-header @back="$router.back()" title="返回" content="停水公告" />
<!-- 搜索和筛选 -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true" :model="queryForm">
<el-form-item label="关键词">
<el-input v-model="queryForm.keyword" placeholder="搜索公告标题/内容" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryForm.type" placeholder="全部" clearable @change="handleSearch">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 公告列表 -->
<div class="notice-list" style="margin-top: 12px">
<el-card shadow="hover" v-for="item in noticeList" :key="item.id" class="notice-item"
@click="viewDetail(item)">
<div class="notice-header">
<div class="notice-title">
<el-tag :type="typeTag(item.type)" size="small" class="type-tag">{{ typeLabel(item.type) }}</el-tag>
<el-tag :type="priorityTag(item.priority)" size="small" v-if="item.priority === 'urgent' || item.priority === 'high'" class="priority-tag">
{{ priorityLabel(item.priority) }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<span class="notice-time">{{ item.publishTime }}</span>
</div>
<div class="notice-meta">
<span v-if="item.affectedArea">📍 {{ item.affectedArea }}</span>
<span v-if="item.plannedStart">🕐 {{ item.plannedStart }} ~ {{ item.plannedEnd }}</span>
</div>
<div class="notice-preview">{{ truncate(item.content, 100) }}</div>
</el-card>
<el-empty v-if="!loading && noticeList.length === 0" description="暂无公告" />
</div>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchNotices" />
<!-- 详情弹窗 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="650px" top="5vh">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="影响范围" :span="2">{{ detailItem?.affectedArea }}</el-descriptions-item>
<el-descriptions-item label="计划开始">{{ detailItem?.plannedStart }}</el-descriptions-item>
<el-descriptions-item label="计划结束">{{ detailItem?.plannedEnd }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="detail-content">{{ detailItem?.content }}</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getNoticeList, getNoticeDetail } from '@/api/wx-hall'
const loading = ref(false)
const showDetail = ref(false)
const noticeList = ref<any[]>([])
const detailItem = ref<any>(null)
const queryForm = reactive({
keyword: '',
type: ''
})
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
onMounted(() => {
fetchNotices()
})
function handleSearch() {
pagination.page = 1
fetchNotices()
}
async function fetchNotices() {
loading.value = true
try {
const res = await getNoticeList({
page: pagination.page,
size: pagination.size,
type: queryForm.type || undefined,
keyword: queryForm.keyword || undefined
})
noticeList.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function viewDetail(row: any) {
try {
const res = await getNoticeDetail(row.id)
detailItem.value = res.data
showDetail.value = true
} catch (e) {
// fallback to list item data
detailItem.value = row
showDetail.value = true
}
}
function truncate(text: string, maxLen: number) {
if (!text) return ''
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
}
const typeMap: Record<string, string> = {
water_outage: '停水',
water_quality: '水质',
maintenance: '维修',
other: '其他'
}
const typeTagMap: Record<string, string> = {
water_outage: 'danger',
water_quality: 'warning',
maintenance: '',
other: 'info'
}
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string): any { return typeTagMap[t] || 'info' }
function priorityLabel(p: string) {
return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p
}
function priorityTag(p: string): any {
return { low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info'
}
</script>
<style scoped>
.wx-notice {
padding: 20px;
}
.notice-item {
margin-bottom: 10px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.notice-item:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.notice-title {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.notice-time {
font-size: 12px;
color: #909399;
white-space: nowrap;
margin-left: 12px;
}
.notice-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.notice-preview {
font-size: 13px;
color: #909399;
line-height: 1.6;
}
.detail-content {
white-space: pre-wrap;
line-height: 1.8;
font-size: 14px;
color: #303133;
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="wx-user-bind">
<el-page-header @back="$router.back()" title="返回" content="用户绑定" />
<!-- OpenID 输入(模拟微信环境) -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true">
<el-form-item label="OpenID">
<el-input v-model="openId" placeholder="请输入微信 OpenID" clearable @clear="handleOpenIdChange" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleOpenIdChange" :disabled="!openId">确认</el-button>
</el-form-item>
</el-form>
</el-card>
<template v-if="openId">
<!-- 手机号绑定 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>📱 手机号绑定</span>
</div>
</template>
<el-form :inline="true" :model="phoneForm">
<el-form-item label="手机号">
<el-input v-model="phoneForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleBindPhone" :loading="phoneLoading"
:disabled="!phoneForm.phone">绑定手机号</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 户号绑定 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>🏠 户号绑定</span>
</div>
</template>
<el-form :inline="true" :model="customerForm">
<el-form-item label="户号">
<el-input v-model="customerForm.customerNo" placeholder="请输入水费户号" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleBindCustomer" :loading="customerLoading"
:disabled="!customerForm.customerNo">绑定户号</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 已绑定列表 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>已绑定列表</span>
<el-button link type="primary" @click="fetchBindings">刷新</el-button>
</div>
</template>
<el-table :data="bindings" border stripe v-loading="bindingsLoading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="bindingType" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.binding_type === 'phone' ? '' : 'success'" size="small">
{{ row.binding_type === 'phone' ? '手机号' : '户号' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="140">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column prop="customerNo" label="户号" width="140">
<template #default="{ row }">{{ row.customer_no || '-' }}</template>
</el-table-column>
<el-table-column prop="customerName" label="客户名称" width="140">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="绑定时间" min-width="170">
<template #default="{ row }">{{ row.created_at }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-popconfirm title="确定解绑吗?" @confirm="handleUnbind(row)">
<template #reference>
<el-button link type="danger">解绑</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!bindingsLoading && bindings.length === 0" description="暂无绑定记录" />
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { bindPhone, bindCustomer, unbind, getBindings } from '@/api/wx-hall'
const openId = ref('')
const phoneLoading = ref(false)
const customerLoading = ref(false)
const bindingsLoading = ref(false)
const bindings = ref<any[]>([])
const phoneForm = reactive({ phone: '' })
const customerForm = reactive({ customerNo: '' })
function handleOpenIdChange() {
if (!openId.value) {
ElMessage.warning('请输入 OpenID')
return
}
fetchBindings()
}
async function handleBindPhone() {
if (!phoneForm.phone) {
ElMessage.warning('请输入手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
ElMessage.warning('手机号格式不正确')
return
}
phoneLoading.value = true
try {
await bindPhone({ openId: openId.value, phone: phoneForm.phone })
ElMessage.success('手机号绑定成功')
phoneForm.phone = ''
fetchBindings()
} catch (e) { /* ignore */ } finally {
phoneLoading.value = false
}
}
async function handleBindCustomer() {
if (!customerForm.customerNo) {
ElMessage.warning('请输入户号')
return
}
customerLoading.value = true
try {
await bindCustomer({ openId: openId.value, customerNo: customerForm.customerNo })
ElMessage.success('户号绑定成功')
customerForm.customerNo = ''
fetchBindings()
} catch (e) { /* ignore */ } finally {
customerLoading.value = false
}
}
async function handleUnbind(row: any) {
try {
await unbind({ openId: openId.value, bindingId: String(row.id) })
ElMessage.success('解绑成功')
fetchBindings()
} catch (e) { /* ignore */ }
}
async function fetchBindings() {
if (!openId.value) return
bindingsLoading.value = true
try {
const res = await getBindings(openId.value)
bindings.value = res.data || []
} finally {
bindingsLoading.value = false
}
}
</script>
<style scoped>
.wx-user-bind {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div class="wx-water-bill">
<el-page-header @back="$router.back()" title="返回" content="水费查询缴费" />
<!-- 用户绑定区域 -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true" :model="queryForm">
<el-form-item label="户号">
<el-input v-model="queryForm.customerNo" placeholder="请输入户号" clearable @clear="handleQuery" />
</el-form-item>
<el-form-item label="账单周期">
<el-date-picker v-model="queryForm.billPeriod" type="month" placeholder="选择月份"
value-format="YYYY-MM" clearable @change="handleQuery" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" placeholder="全部" clearable @change="handleQuery">
<el-option label="待缴" value="pending" />
<el-option label="已缴" value="paid" />
<el-option label="部分" value="partial" />
<el-option label="逾期" value="overdue" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 账单列表 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>账单列表</span>
<el-tag v-if="totalUnpaid > 0" type="danger">待缴 ¥{{ totalUnpaid.toFixed(2) }}</el-tag>
</div>
</template>
<el-table :data="billList" v-loading="loading" border stripe>
<el-table-column prop="billNo" label="账单编号" min-width="180" show-overflow-tooltip />
<el-table-column prop="billPeriod" label="账单周期" width="110" />
<el-table-column prop="waterUsage" label="用水量(吨)" width="100" align="right">
<template #default="{ row }">{{ Number(row.waterUsage).toFixed(1) }}</template>
</el-table-column>
<el-table-column prop="totalAmount" label="应缴(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.totalAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="paidAmount" label="已缴(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.paidAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="dueDate" label="到期日" width="110" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status !== 'paid' && row.status !== 'cancelled'"
@click="openPayDialog(row)">缴费</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchBills" />
</el-card>
<!-- 缴费记录 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>缴费记录</span>
<el-button link type="primary" @click="fetchPaymentRecords">刷新</el-button>
</div>
</template>
<el-table :data="paymentRecords" border stripe size="small">
<el-table-column prop="paymentNo" label="流水号" min-width="180" show-overflow-tooltip />
<el-table-column prop="billNo" label="账单编号" width="180" show-overflow-tooltip />
<el-table-column prop="amount" label="金额(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="channelName" label="渠道" width="100" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'info'" size="small">
{{ row.status === 'success' ? '成功' : row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="payTime" label="支付时间" width="170" />
</el-table>
</el-card>
<!-- 账单详情弹窗 -->
<el-dialog v-model="showDetail" title="账单详情" width="550px">
<el-descriptions :column="2" border v-if="detailData">
<el-descriptions-item label="账单编号">{{ detailData.bill?.billNo }}</el-descriptions-item>
<el-descriptions-item label="账单周期">{{ detailData.bill?.billPeriod }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ detailData.bill?.customerName }}</el-descriptions-item>
<el-descriptions-item label="户号">{{ detailData.bill?.customerNo }}</el-descriptions-item>
<el-descriptions-item label="水表编号">{{ detailData.bill?.meterNo }}</el-descriptions-item>
<el-descriptions-item label="用水量">{{ Number(detailData.bill?.waterUsage || 0).toFixed(1) }} 吨</el-descriptions-item>
<el-descriptions-item label="单价">¥{{ Number(detailData.bill?.unitPrice || 0).toFixed(2) }}/吨</el-descriptions-item>
<el-descriptions-item label="应缴金额">¥{{ Number(detailData.bill?.totalAmount || 0).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="已缴金额">¥{{ Number(detailData.bill?.paidAmount || 0).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="剩余金额">
<el-tag :type="detailData.remainingAmount > 0 ? 'danger' : 'success'">
¥{{ Number(detailData.remainingAmount || 0).toFixed(2) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="抄表读数">
{{ detailData.bill?.prevReading }} → {{ detailData.bill?.currReading }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detailData.bill?.status)">{{ statusLabel(detailData.bill?.status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider v-if="detailData?.payments?.length > 0">缴费记录</el-divider>
<el-table :data="detailData?.payments || []" size="small" border v-if="detailData?.payments?.length > 0">
<el-table-column prop="paymentNo" label="流水号" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="channelName" label="渠道" width="100" />
<el-table-column prop="payTime" label="时间" width="170" />
</el-table>
</el-dialog>
<!-- 缴费弹窗 -->
<el-dialog v-model="showPay" title="在线缴费" width="420px">
<div v-if="payingBill" class="pay-info">
<el-descriptions :column="1" border>
<el-descriptions-item label="账单编号">{{ payingBill.billNo }}</el-descriptions-item>
<el-descriptions-item label="账单周期">{{ payingBill.billPeriod }}</el-descriptions-item>
<el-descriptions-item label="应缴金额">¥{{ Number(payingBill.totalAmount).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="已缴金额">¥{{ Number(payingBill.paidAmount).toFixed(2) }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<el-form-item label="缴费金额">
<el-input-number v-model="payAmount" :min="0.01"
:max="Number(payingBill.totalAmount) - Number(payingBill.paidAmount)"
:precision="2" :step="1" style="width: 100%" />
</el-form-item>
</div>
<template #footer>
<el-button @click="showPay = false">取消</el-button>
<el-button type="primary" :loading="payLoading" @click="handlePay">确认缴费</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getBillList, getBillDetail, payBill, getPaymentRecords } from '@/api/wx-hall'
const loading = ref(false)
const payLoading = ref(false)
const showDetail = ref(false)
const showPay = ref(false)
const billList = ref<any[]>([])
const paymentRecords = ref<any[]>([])
const detailData = ref<any>(null)
const payingBill = ref<any>(null)
const payAmount = ref(0)
const queryForm = reactive({
customerNo: '',
billPeriod: '',
status: ''
})
const pagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0
})
const totalUnpaid = computed(() => {
return billList.value
.filter(b => b.status !== 'paid' && b.status !== 'cancelled')
.reduce((sum, b) => sum + (Number(b.totalAmount) - Number(b.paidAmount)), 0)
})
onMounted(() => {
// 自动加载(需要提供 customerNo)
})
function handleQuery() {
if (!queryForm.customerNo) {
ElMessage.warning('请输入户号')
return
}
pagination.pageNum = 1
fetchBills()
fetchPaymentRecords()
}
async function fetchBills() {
if (!queryForm.customerNo) return
loading.value = true
try {
const res = await getBillList({
customerNo: queryForm.customerNo,
billPeriod: queryForm.billPeriod || undefined,
status: queryForm.status || undefined,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
})
billList.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchPaymentRecords() {
if (!queryForm.customerNo) return
try {
const res = await getPaymentRecords({ customerNo: queryForm.customerNo, limit: 20 })
paymentRecords.value = res.data || []
} catch (e) { /* ignore */ }
}
async function viewDetail(row: any) {
try {
const res = await getBillDetail(row.id)
detailData.value = res.data
showDetail.value = true
} catch (e) { /* ignore */ }
}
function openPayDialog(row: any) {
payingBill.value = row
const remaining = Number(row.totalAmount) - Number(row.paidAmount)
payAmount.value = Math.round(remaining * 100) / 100
showPay.value = true
}
async function handlePay() {
if (!payingBill.value || payAmount.value <= 0) {
ElMessage.warning('请输入正确的缴费金额')
return
}
payLoading.value = true
try {
await payBill({ billId: payingBill.value.id, amount: payAmount.value })
ElMessage.success('缴费成功!')
showPay.value = false
fetchBills()
fetchPaymentRecords()
} catch (e) { /* ignore */ } finally {
payLoading.value = false
}
}
function statusLabel(s: string) {
const map: Record<string, string> = {
pending: '待缴', paid: '已缴', partial: '部分', overdue: '逾期', cancelled: '已作废'
}
return map[s] || s
}
function statusTag(s: string): any {
const map: Record<string, string> = {
pending: 'warning', paid: 'success', partial: '', overdue: 'danger', cancelled: 'info'
}
return map[s] || 'info'
}
</script>
<style scoped>
.wx-water-bill {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pay-info {
padding: 0 12px;
}
</style>
@@ -0,0 +1,220 @@
package com.water.revenue.controller.wxhall;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.revenue.entity.Announcement;
import com.water.revenue.entity.PaymentRecord;
import com.water.revenue.entity.WaterBill;
import com.water.revenue.service.AnnouncementService;
import com.water.revenue.service.BillService;
import com.water.revenue.service.InstallService;
import com.water.revenue.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
/**
* 微信网厅 API(移动端适配接口)
* 所有路径前缀: /api/wx-hall/*
*/
@Slf4j
@Tag(name = "微信网厅 API")
@RestController
@RequestMapping("/wx-hall")
@RequiredArgsConstructor
public class WxHallApiController {
private final BillService billService;
private final InstallService installService;
private final AnnouncementService announcementService;
private final PaymentService paymentService;
private final JdbcTemplate jdbcTemplate;
// ========== 水费查询缴费 ==========
@Operation(summary = "查询用户账单列表")
@GetMapping("/bill/list")
public R<Page<WaterBill>> billList(
@RequestParam String customerNo,
@RequestParam(required = false) String billPeriod,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
}
@Operation(summary = "账单详情")
@GetMapping("/bill/{billId}")
public R<Map<String, Object>> billDetail(@PathVariable Long billId) {
return R.ok(billService.getBillDetail(billId));
}
@Operation(summary = "在线缴费(微信下单)")
@PostMapping("/bill/pay")
public R<Map<String, Object>> billPay(@RequestBody Map<String, Object> req) {
Long billId = Long.valueOf(req.get("billId").toString());
BigDecimal amount = new BigDecimal(req.get("amount").toString());
return R.ok(paymentService.payByWechat(billId, amount));
}
@Operation(summary = "缴费记录")
@GetMapping("/bill/payment-records")
public R<List<Map<String, Object>>> paymentRecords(
@RequestParam String customerNo,
@RequestParam(defaultValue = "20") int limit) {
List<Map<String, Object>> records = jdbcTemplate.queryForList(
"SELECT payment_no, bill_no, amount, channel, channel_name, status, pay_time, remark " +
"FROM wm_payment_record WHERE customer_no = ? ORDER BY pay_time DESC LIMIT ?",
customerNo, limit);
return R.ok(records);
}
// ========== 报装申请 ==========
@Operation(summary = "提交报装申请")
@PostMapping("/install/apply")
public R<Map<String, Object>> installApply(@RequestBody Map<String, String> req) {
return R.ok(installService.preApply(
req.get("name"),
req.get("phone"),
req.get("area"),
req.get("address"),
req.getOrDefault("customerType", "resident"),
req.getOrDefault("caliber", "DN15")));
}
@Operation(summary = "查询报装进度")
@GetMapping("/install/progress")
public R<Map<String, Object>> installProgress(@RequestParam String applyNo) {
return R.ok(installService.getProgress(applyNo));
}
@Operation(summary = "报装申请记录列表")
@GetMapping("/install/list")
public R<List<Map<String, Object>>> installList(
@RequestParam String phone,
@RequestParam(defaultValue = "20") int limit) {
List<Map<String, Object>> list = jdbcTemplate.queryForList(
"SELECT application_no, applicant_name, applicant_phone, area, address, " +
"customer_type, caliber, status, created_at, updated_at " +
"FROM rev_install WHERE applicant_phone = ? ORDER BY created_at DESC LIMIT ?",
phone, limit);
return R.ok(list);
}
// ========== 停水公告 ==========
@Operation(summary = "公告列表")
@GetMapping("/notice/list")
public R<Page<Announcement>> noticeList(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String type,
@RequestParam(required = false) String keyword) {
// 只返回已发布的公告
return R.ok(announcementService.list(page, size, type, 1, keyword));
}
@Operation(summary = "公告详情")
@GetMapping("/notice/{id}")
public R<Announcement> noticeDetail(@PathVariable Long id) {
return R.ok(announcementService.getDetail(id));
}
@Operation(summary = "获取当前生效公告(按区域)")
@GetMapping("/notice/active")
public R<List<Announcement>> activeNotices(
@RequestParam(required = false) String areaCode) {
return R.ok(announcementService.getActiveAnnouncements(areaCode));
}
// ========== 用户绑定 ==========
@Operation(summary = "手机号绑定")
@PostMapping("/user/bindPhone")
public R<Map<String, Object>> bindPhone(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String phone = req.get("phone");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(phone)) {
return R.fail("openId和phone不能为空");
}
// 检查是否已绑定
Long existCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND phone = ? AND status = 1",
Long.class, openId, phone);
if (existCount != null && existCount > 0) {
return R.fail("该手机号已绑定");
}
jdbcTemplate.update(
"INSERT INTO wx_hall_user_binding (open_id, phone, binding_type, status) VALUES (?, ?, 'phone', 1)",
openId, phone);
log.info("User bound phone: openId={}, phone={}", openId, phone);
return R.ok(Map.of("openId", openId, "phone", phone, "status", "bound"));
}
@Operation(summary = "户号绑定")
@PostMapping("/user/bindCustomer")
public R<Map<String, Object>> bindCustomer(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String customerNo = req.get("customerNo");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(customerNo)) {
return R.fail("openId和customerNo不能为空");
}
// 检查是否已绑定
Long existCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND customer_no = ? AND status = 1",
Long.class, openId, customerNo);
if (existCount != null && existCount > 0) {
return R.fail("该户号已绑定");
}
// 查询客户名称
String customerName = null;
try {
customerName = jdbcTemplate.queryForObject(
"SELECT customer_name FROM rev_customer WHERE customer_no = ?",
String.class, customerNo);
} catch (Exception e) {
log.warn("Customer not found: {}", customerNo);
}
jdbcTemplate.update(
"INSERT INTO wx_hall_user_binding (open_id, customer_no, customer_name, binding_type, status) VALUES (?, ?, ?, 'customer_no', 1)",
openId, customerNo, customerName);
log.info("User bound customer: openId={}, customerNo={}", openId, customerNo);
return R.ok(Map.of("openId", openId, "customerNo", customerNo,
"customerName", customerName != null ? customerName : "", "status", "bound"));
}
@Operation(summary = "解绑")
@PostMapping("/user/unbind")
public R<Map<String, Object>> unbind(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String bindingId = req.get("bindingId");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(bindingId)) {
return R.fail("参数不完整");
}
jdbcTemplate.update(
"UPDATE wx_hall_user_binding SET status = 0, updated_at = NOW() WHERE id = ? AND open_id = ?",
Long.valueOf(bindingId), openId);
log.info("User unbound: openId={}, bindingId={}", openId, bindingId);
return R.ok(Map.of("status", "unbound"));
}
@Operation(summary = "查询用户绑定列表")
@GetMapping("/user/bindings")
public R<List<Map<String, Object>>> bindings(@RequestParam String openId) {
List<Map<String, Object>> list = jdbcTemplate.queryForList(
"SELECT id, phone, customer_no, customer_name, binding_type, status, created_at " +
"FROM wx_hall_user_binding WHERE open_id = ? AND status = 1 ORDER BY created_at DESC",
openId);
return R.ok(list);
}
}
@@ -0,0 +1,18 @@
-- 微信网厅用户绑定表(手机号/户号绑定)
CREATE TABLE IF NOT EXISTS wx_hall_user_binding (
id BIGSERIAL PRIMARY KEY,
open_id VARCHAR(64) NOT NULL,
phone VARCHAR(20),
customer_no VARCHAR(32),
customer_name VARCHAR(100),
binding_type VARCHAR(20) NOT NULL DEFAULT 'phone', -- phone / customer_no
status INTEGER NOT NULL DEFAULT 1, -- 1-有效 0-已解绑
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_wx_binding_open_id ON wx_hall_user_binding(open_id);
CREATE INDEX IF NOT EXISTS idx_wx_binding_phone ON wx_hall_user_binding(phone);
CREATE INDEX IF NOT EXISTS idx_wx_binding_customer ON wx_hall_user_binding(customer_no);
CREATE INDEX IF NOT EXISTS idx_wx_binding_status ON wx_hall_user_binding(status);