Explorar o código

feat(web-admin): add dashboard, ledger, and config pages

Wire admin dashboard stats, feedback/suggestion drawers with handle
permissions, and hotline/qrcode configuration forms to backend APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
3c45a1ad36

+ 57 - 0
web-admin/src/api/admin.ts

@@ -0,0 +1,57 @@
+import http from '@/utils/http'
+
+export function fetchDashboard() {
+  return http.get('/web/admin/dashboard')
+}
+
+export function fetchFeedbacks(params: Record<string, any>) {
+  return http.get('/web/admin/feedbacks', { params })
+}
+
+export function fetchFeedback(id: string) {
+  return http.get(`/web/admin/feedbacks/${id}`)
+}
+
+export function updateFeedbackStatus(id: string, data: { status: string; handleRemark?: string }) {
+  return http.put(`/web/admin/feedbacks/${id}/status`, data)
+}
+
+export function fetchSuggestions(params: Record<string, any>) {
+  return http.get('/web/admin/suggestions', { params })
+}
+
+export function fetchSuggestion(id: string) {
+  return http.get(`/web/admin/suggestions/${id}`)
+}
+
+export function updateSuggestionStatus(
+  id: string,
+  data: { status: string; handleRemark?: string }
+) {
+  return http.put(`/web/admin/suggestions/${id}/status`, data)
+}
+
+export function fetchHotline() {
+  return http.get('/web/admin/hotline')
+}
+
+export function saveHotline(data: Record<string, any>) {
+  return http.put('/web/admin/hotline', data)
+}
+
+export function fetchQrcode() {
+  return http.get('/web/admin/qrcode')
+}
+
+export function saveQrcode(data: Record<string, any>) {
+  return http.put('/web/admin/qrcode', data)
+}
+
+export function uploadAdminFile(file: File, folder = 'admin') {
+  const fd = new FormData()
+  fd.append('file', file)
+  fd.append('folder', folder)
+  return http.post('/web/admin/attachment/upload', fd, {
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}

+ 4 - 4
web-admin/src/router/index.ts

@@ -13,25 +13,25 @@ const businessRoutes: RouteRecordRaw[] = [
   {
   {
     path: 'feedback',
     path: 'feedback',
     name: 'feedback',
     name: 'feedback',
-    component: Placeholder,
+    component: () => import('@/views/feedback/index.vue'),
     meta: { menuCode: 'feedback', title: '在线反馈' }
     meta: { menuCode: 'feedback', title: '在线反馈' }
   },
   },
   {
   {
     path: 'suggestion',
     path: 'suggestion',
     name: 'suggestion',
     name: 'suggestion',
-    component: Placeholder,
+    component: () => import('@/views/suggestion/index.vue'),
     meta: { menuCode: 'suggestion', title: '建言献策' }
     meta: { menuCode: 'suggestion', title: '建言献策' }
   },
   },
   {
   {
     path: 'hotline',
     path: 'hotline',
     name: 'hotline',
     name: 'hotline',
-    component: Placeholder,
+    component: () => import('@/views/hotline/index.vue'),
     meta: { menuCode: 'hotline', title: '热线配置' }
     meta: { menuCode: 'hotline', title: '热线配置' }
   },
   },
   {
   {
     path: 'qrcode',
     path: 'qrcode',
     name: 'qrcode',
     name: 'qrcode',
-    component: Placeholder,
+    component: () => import('@/views/qrcode/index.vue'),
     meta: { menuCode: 'qrcode', title: '二维码配置' }
     meta: { menuCode: 'qrcode', title: '二维码配置' }
   },
   },
   {
   {

+ 69 - 18
web-admin/src/views/dashboard/index.vue

@@ -1,24 +1,48 @@
 <template>
 <template>
   <div class="panel">
   <div class="panel">
     <h2>工作台</h2>
     <h2>工作台</h2>
-    <p class="hint">登录与动态菜单已接通;业务统计页将在后续任务完善。</p>
-    <el-descriptions :column="1" border>
-      <el-descriptions-item label="当前用户">
-        {{ userStore.user?.displayName }}({{ userStore.user?.roleName }})
-      </el-descriptions-item>
-      <el-descriptions-item label="菜单数">
-        {{ userStore.menus?.length || 0 }}
-      </el-descriptions-item>
-      <el-descriptions-item label="权限点数">
-        {{ userStore.permCodes?.length || 0 }}
-      </el-descriptions-item>
-    </el-descriptions>
+    <el-row :gutter="16" class="stats">
+      <el-col :span="6" v-for="s in statCards" :key="s.label">
+        <div class="stat-card">
+          <div class="label">{{ s.label }}</div>
+          <div class="value">{{ s.value }}</div>
+          <div class="sub">{{ s.sub }}</div>
+        </div>
+      </el-col>
+    </el-row>
+    <div class="block">
+      <div class="block-head">
+        <h3>最近反馈</h3>
+        <el-button link type="primary" @click="$router.push('/feedback')">查看全部</el-button>
+      </div>
+      <el-table :data="recent" stripe>
+        <el-table-column prop="bizNo" label="编号" min-width="140" />
+        <el-table-column prop="fbType" label="类型" width="90" />
+        <el-table-column prop="content" label="内容摘要" min-width="220" show-overflow-tooltip />
+        <el-table-column prop="createTime" label="提交时间" min-width="160" />
+        <el-table-column prop="status" label="状态" width="100" />
+      </el-table>
+    </div>
   </div>
   </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import { useUserStore } from '@/stores/user'
-  const userStore = useUserStore()
+  import { computed, onMounted, ref } from 'vue'
+  import { fetchDashboard } from '@/api/admin'
+
+  const data = ref<any>({})
+  const recent = computed(() => data.value.recentFeedback || [])
+  const statCards = computed(() => [
+    { label: '在线反馈总数', value: data.value.feedbackTotal ?? 0, sub: '含投诉 / 表扬' },
+    { label: '投诉待办', value: data.value.complaintPending ?? 0, sub: '需及时核实办理' },
+    { label: '建言献策', value: data.value.suggestionTotal ?? 0, sub: '群众建议条目' },
+    { label: '本周新增', value: data.value.weekNew ?? 0, sub: '反馈 + 建议合计' }
+  ])
+
+  onMounted(async () => {
+    const res: any = await fetchDashboard()
+    data.value = res.data || {}
+  })
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
@@ -28,12 +52,39 @@
     padding: 20px 22px;
     padding: 20px 22px;
   }
   }
   h2 {
   h2 {
-    margin: 0 0 8px;
-    font-size: 18px;
+    margin: 0 0 16px;
+  }
+  .stats {
+    margin-bottom: 20px;
+  }
+  .stat-card {
+    background: #fafafa;
+    border: 1px solid #eee;
+    border-radius: 10px;
+    padding: 16px;
   }
   }
-  .hint {
+  .stat-card .label {
     color: #888;
     color: #888;
     font-size: 13px;
     font-size: 13px;
-    margin-bottom: 16px;
+  }
+  .stat-card .value {
+    font-size: 28px;
+    font-weight: 700;
+    margin: 6px 0;
+    color: #a51f23;
+  }
+  .stat-card .sub {
+    color: #aaa;
+    font-size: 12px;
+  }
+  .block-head {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 10px;
+  }
+  h3 {
+    margin: 0;
+    font-size: 15px;
   }
   }
 </style>
 </style>

+ 176 - 0
web-admin/src/views/feedback/index.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <el-select v-model="query.status" clearable placeholder="全部状态" style="width: 140px">
+        <el-option label="待处理" value="待处理" />
+        <el-option label="处理中" value="处理中" />
+        <el-option label="已办结" value="已办结" />
+      </el-select>
+      <el-select v-model="query.fbType" clearable placeholder="全部类型" style="width: 140px">
+        <el-option label="投诉" value="投诉" />
+        <el-option label="表扬" value="表扬" />
+      </el-select>
+      <el-input v-model="query.keyword" clearable placeholder="关键词" style="width: 220px" />
+      <el-button type="primary" @click="load">查询</el-button>
+    </div>
+    <el-table :data="list" v-loading="loading" stripe>
+      <el-table-column prop="bizNo" label="编号" min-width="140" />
+      <el-table-column prop="fbType" label="类型" width="90" />
+      <el-table-column prop="targetName" label="被反馈人" width="110" />
+      <el-table-column prop="content" label="内容摘要" min-width="200" show-overflow-tooltip />
+      <el-table-column prop="reporterName" label="举报人" width="100" />
+      <el-table-column prop="createTime" label="提交时间" min-width="160" />
+      <el-table-column prop="status" label="状态" width="100" />
+      <el-table-column label="操作" width="100" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDetail(row)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="pager">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        v-model:current-page="query.page"
+        :page-size="query.pageSize"
+        @current-change="load"
+      />
+    </div>
+
+    <el-drawer v-model="drawer" title="反馈详情" size="480px">
+      <template v-if="detail">
+        <el-descriptions :column="1" border>
+          <el-descriptions-item label="编号">{{ detail.bizNo }}</el-descriptions-item>
+          <el-descriptions-item label="类型">{{ detail.fbType }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item>
+          <el-descriptions-item label="被反馈人">
+            {{ detail.targetName || '-' }} / {{ detail.targetDept || '-' }} /
+            {{ detail.targetPost || '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="内容">{{ detail.content }}</el-descriptions-item>
+          <el-descriptions-item label="举报人">
+            {{ detail.reporterName || '匿名' }} {{ detail.reporterPhone || '' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="附件">
+            <div v-if="detail.attachmentUrls?.length">
+              <a
+                v-for="(u, i) in detail.attachmentUrls"
+                :key="i"
+                :href="u"
+                target="_blank"
+                rel="noreferrer"
+                >附件{{ i + 1 }}</a
+              >
+            </div>
+            <span v-else>无</span>
+          </el-descriptions-item>
+        </el-descriptions>
+        <div v-if="canHandle" class="handle-box">
+          <h4>办理</h4>
+          <el-select v-model="handleForm.status" style="width: 100%; margin-bottom: 10px">
+            <el-option label="待处理" value="待处理" />
+            <el-option label="处理中" value="处理中" />
+            <el-option label="已办结" value="已办结" />
+          </el-select>
+          <el-input
+            v-model="handleForm.handleRemark"
+            type="textarea"
+            :rows="3"
+            placeholder="办理备注"
+          />
+          <el-button type="primary" style="margin-top: 12px" :loading="saving" @click="saveHandle"
+            >保存</el-button
+          >
+        </div>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, reactive, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchFeedback, fetchFeedbacks, updateFeedbackStatus } from '@/api/admin'
+  import { useUserStore } from '@/stores/user'
+
+  const userStore = useUserStore()
+  const canHandle = computed(() => userStore.hasPerm('feedback:handle'))
+  const loading = ref(false)
+  const saving = ref(false)
+  const list = ref<any[]>([])
+  const total = ref(0)
+  const drawer = ref(false)
+  const detail = ref<any>(null)
+  const query = reactive({
+    status: '',
+    fbType: '',
+    keyword: '',
+    page: 1,
+    pageSize: 10
+  })
+  const handleForm = reactive({ status: '', handleRemark: '' })
+
+  async function load() {
+    loading.value = true
+    try {
+      const res: any = await fetchFeedbacks({ ...query })
+      list.value = res.data.list || []
+      total.value = res.data.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function openDetail(row: any) {
+    const res: any = await fetchFeedback(row.id)
+    detail.value = res.data
+    handleForm.status = res.data.status
+    handleForm.handleRemark = res.data.handleRemark || ''
+    drawer.value = true
+  }
+
+  async function saveHandle() {
+    if (!detail.value) return
+    saving.value = true
+    try {
+      const res: any = await updateFeedbackStatus(detail.value.id, {
+        status: handleForm.status,
+        handleRemark: handleForm.handleRemark
+      })
+      detail.value = res.data
+      ElMessage.success('已保存')
+      load()
+    } finally {
+      saving.value = false
+    }
+  }
+
+  onMounted(load)
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px;
+  }
+  .toolbar {
+    display: flex;
+    gap: 10px;
+    margin-bottom: 14px;
+    flex-wrap: wrap;
+  }
+  .pager {
+    margin-top: 14px;
+    display: flex;
+    justify-content: flex-end;
+  }
+  .handle-box {
+    margin-top: 20px;
+  }
+  a {
+    display: block;
+    color: #a51f23;
+  }
+</style>

+ 70 - 0
web-admin/src/views/hotline/index.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="panel">
+    <h2>监督热线配置</h2>
+    <p class="desc">配置后同步至移动端「监督热线」页面展示。</p>
+    <el-form :model="form" label-position="top" style="max-width: 520px" :disabled="!canWrite">
+      <el-form-item label="监督电话">
+        <el-input v-model="form.phone" />
+      </el-form-item>
+      <el-form-item label="电子邮箱">
+        <el-input v-model="form.email" />
+      </el-form-item>
+      <el-form-item label="通讯地址">
+        <el-input v-model="form.address" />
+      </el-form-item>
+      <el-form-item label="受理时间说明">
+        <el-input v-model="form.workTime" type="textarea" :rows="3" />
+      </el-form-item>
+      <el-button v-if="canWrite" type="primary" :loading="saving" @click="save">保存</el-button>
+    </el-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, reactive, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchHotline, saveHotline } from '@/api/admin'
+  import { useUserStore } from '@/stores/user'
+
+  const userStore = useUserStore()
+  const canWrite = computed(() => userStore.hasPerm('hotline:write'))
+  const saving = ref(false)
+  const form = reactive({
+    phone: '',
+    email: '',
+    address: '',
+    workTime: ''
+  })
+
+  onMounted(async () => {
+    const res: any = await fetchHotline()
+    Object.assign(form, res.data || {})
+  })
+
+  async function save() {
+    saving.value = true
+    try {
+      const res: any = await saveHotline({ ...form })
+      Object.assign(form, res.data || {})
+      ElMessage.success('已保存')
+    } finally {
+      saving.value = false
+    }
+  }
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px 22px;
+  }
+  h2 {
+    margin: 0 0 6px;
+  }
+  .desc {
+    color: #888;
+    font-size: 13px;
+    margin-bottom: 16px;
+  }
+</style>

+ 119 - 0
web-admin/src/views/qrcode/index.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="panel">
+    <h2>二维码配置</h2>
+    <p class="desc">设置扫码落地页,或上传自定义二维码图片,供打印张贴与移动端分享使用。</p>
+    <el-row :gutter="24">
+      <el-col :span="14">
+        <el-form :model="form" label-position="top" :disabled="!canWrite">
+          <el-form-item label="落地链接">
+            <el-input v-model="form.landUrl" placeholder="https://" />
+          </el-form-item>
+          <el-form-item label="二维码说明">
+            <el-input v-model="form.title" placeholder="如:安建医院纪检 · 扫码反馈" />
+          </el-form-item>
+          <el-form-item label="自定义二维码图片">
+            <el-upload
+              :show-file-list="false"
+              :disabled="!canWrite"
+              :http-request="onUpload"
+              accept="image/*"
+            >
+              <el-button>选择图片上传</el-button>
+            </el-upload>
+            <div v-if="form.imageUrl" class="upload-name">已配置自定义图片</div>
+          </el-form-item>
+          <el-button v-if="canWrite" type="primary" :loading="saving" @click="save">保存</el-button>
+        </el-form>
+      </el-col>
+      <el-col :span="10">
+        <div class="preview">
+          <h3>预览</h3>
+          <p>{{ form.title || '安建医院纪检 · 扫码反馈' }}</p>
+          <img v-if="form.imageUrl" :src="form.imageUrl" alt="qrcode" />
+          <div v-else class="url-box">{{ form.landUrl || '未配置落地链接' }}</div>
+        </div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, reactive, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import type { UploadRequestOptions } from 'element-plus'
+  import { fetchQrcode, saveQrcode, uploadAdminFile } from '@/api/admin'
+  import { useUserStore } from '@/stores/user'
+
+  const userStore = useUserStore()
+  const canWrite = computed(() => userStore.hasPerm('qrcode:write'))
+  const saving = ref(false)
+  const form = reactive({
+    landUrl: '',
+    title: '',
+    imageUrl: ''
+  })
+
+  onMounted(async () => {
+    const res: any = await fetchQrcode()
+    Object.assign(form, res.data || {})
+  })
+
+  async function onUpload(opt: UploadRequestOptions) {
+    try {
+      const res: any = await uploadAdminFile(opt.file as File, 'qrcode')
+      form.imageUrl = res.data.url
+      ElMessage.success('图片已上传,请点击保存')
+      opt.onSuccess?.(res as any)
+    } catch (e) {
+      opt.onError?.(e as any)
+    }
+  }
+
+  async function save() {
+    saving.value = true
+    try {
+      const res: any = await saveQrcode({ ...form })
+      Object.assign(form, res.data || {})
+      ElMessage.success('已保存')
+    } finally {
+      saving.value = false
+    }
+  }
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px 22px;
+  }
+  h2 {
+    margin: 0 0 6px;
+  }
+  .desc {
+    color: #888;
+    font-size: 13px;
+    margin-bottom: 16px;
+  }
+  .preview {
+    border: 1px solid #eee;
+    border-radius: 10px;
+    padding: 16px;
+    text-align: center;
+  }
+  .preview img {
+    max-width: 220px;
+    margin-top: 12px;
+  }
+  .url-box {
+    margin-top: 12px;
+    word-break: break-all;
+    color: #666;
+    font-size: 13px;
+  }
+  .upload-name {
+    margin-top: 8px;
+    color: #888;
+    font-size: 12px;
+  }
+</style>

+ 145 - 0
web-admin/src/views/suggestion/index.vue

@@ -0,0 +1,145 @@
+<template>
+  <div class="panel">
+    <div class="toolbar">
+      <el-select v-model="query.status" clearable placeholder="全部状态" style="width: 140px">
+        <el-option label="待查阅" value="待查阅" />
+        <el-option label="已采纳" value="已采纳" />
+        <el-option label="已回复" value="已回复" />
+      </el-select>
+      <el-input v-model="query.keyword" clearable placeholder="关键词" style="width: 220px" />
+      <el-button type="primary" @click="load">查询</el-button>
+    </div>
+    <el-table :data="list" v-loading="loading" stripe>
+      <el-table-column prop="bizNo" label="编号" min-width="140" />
+      <el-table-column prop="content" label="建议内容" min-width="220" show-overflow-tooltip />
+      <el-table-column prop="contactName" label="联系人" width="100" />
+      <el-table-column prop="contactUnit" label="单位" min-width="120" />
+      <el-table-column prop="contactPhone" label="联系方式" width="120" />
+      <el-table-column prop="createTime" label="提交时间" min-width="160" />
+      <el-table-column prop="status" label="状态" width="100" />
+      <el-table-column label="操作" width="100" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDetail(row)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div class="pager">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        v-model:current-page="query.page"
+        :page-size="query.pageSize"
+        @current-change="load"
+      />
+    </div>
+
+    <el-drawer v-model="drawer" title="建言详情" size="480px">
+      <template v-if="detail">
+        <el-descriptions :column="1" border>
+          <el-descriptions-item label="编号">{{ detail.bizNo }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item>
+          <el-descriptions-item label="内容">{{ detail.content }}</el-descriptions-item>
+          <el-descriptions-item label="联系人">
+            {{ detail.contactName || '-' }} / {{ detail.contactUnit || '-' }} /
+            {{ detail.contactPost || '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="联系方式">{{ detail.contactPhone || '-' }}</el-descriptions-item>
+        </el-descriptions>
+        <div v-if="canHandle" class="handle-box">
+          <h4>办理</h4>
+          <el-select v-model="handleForm.status" style="width: 100%; margin-bottom: 10px">
+            <el-option label="待查阅" value="待查阅" />
+            <el-option label="已采纳" value="已采纳" />
+            <el-option label="已回复" value="已回复" />
+          </el-select>
+          <el-input
+            v-model="handleForm.handleRemark"
+            type="textarea"
+            :rows="3"
+            placeholder="办理备注"
+          />
+          <el-button type="primary" style="margin-top: 12px" :loading="saving" @click="saveHandle"
+            >保存</el-button
+          >
+        </div>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, reactive, ref } from 'vue'
+  import { ElMessage } from 'element-plus'
+  import { fetchSuggestion, fetchSuggestions, updateSuggestionStatus } from '@/api/admin'
+  import { useUserStore } from '@/stores/user'
+
+  const userStore = useUserStore()
+  const canHandle = computed(() => userStore.hasPerm('suggestion:handle'))
+  const loading = ref(false)
+  const saving = ref(false)
+  const list = ref<any[]>([])
+  const total = ref(0)
+  const drawer = ref(false)
+  const detail = ref<any>(null)
+  const query = reactive({ status: '', keyword: '', page: 1, pageSize: 10 })
+  const handleForm = reactive({ status: '', handleRemark: '' })
+
+  async function load() {
+    loading.value = true
+    try {
+      const res: any = await fetchSuggestions({ ...query })
+      list.value = res.data.list || []
+      total.value = res.data.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function openDetail(row: any) {
+    const res: any = await fetchSuggestion(row.id)
+    detail.value = res.data
+    handleForm.status = res.data.status
+    handleForm.handleRemark = res.data.handleRemark || ''
+    drawer.value = true
+  }
+
+  async function saveHandle() {
+    if (!detail.value) return
+    saving.value = true
+    try {
+      const res: any = await updateSuggestionStatus(detail.value.id, {
+        status: handleForm.status,
+        handleRemark: handleForm.handleRemark
+      })
+      detail.value = res.data
+      ElMessage.success('已保存')
+      load()
+    } finally {
+      saving.value = false
+    }
+  }
+
+  onMounted(load)
+</script>
+
+<style scoped>
+  .panel {
+    background: #fff;
+    border-radius: 10px;
+    padding: 20px;
+  }
+  .toolbar {
+    display: flex;
+    gap: 10px;
+    margin-bottom: 14px;
+  }
+  .pager {
+    margin-top: 14px;
+    display: flex;
+    justify-content: flex-end;
+  }
+  .handle-box {
+    margin-top: 20px;
+  }
+</style>