Explorar el Código

feat(mobile-wx): add H5 feedback portal pages wired to public API

Implement home, notice countdown, feedback/suggest forms with upload,
and hotline page; add deploy guide and acceptance checklist docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 2 meses
padre
commit
fe6f4b45ec

+ 11 - 3
backend/.env.example

@@ -1,17 +1,25 @@
-# ajyy backend environment example
+# API
 APP_NAME=ajyy
 API_V1_PREFIX=/api
-DATABASE_URL=sqlite+pysqlite:///./ajyy.db
+
+# Database — local tests can use sqlite; shared test env uses MySQL library `ajyy`
+# DATABASE_URL=sqlite+pysqlite:///:memory:
+DATABASE_URL=mysql+pymysql://USER:PASSWORD@HOST:3306/ajyy?charset=utf8mb4
+
+# JWT
 SECRET_KEY=change-me-in-production
 ACCESS_TOKEN_EXPIRE_MINUTES=720
 ALGORITHM=HS256
+
+# Seed admin (created only when DB empty of roles)
 INIT_ADMIN_USERNAME=admin
 INIT_ADMIN_PASSWORD=Admin@123
 
-# local | minio
+# Storage: local | minio
 STORAGE_TYPE=local
 LOCAL_STORAGE_DIR=./uploads
 
+# MinIO (shared test env; use dedicated bucket)
 MINIO_ENDPOINT=127.0.0.1:9000
 MINIO_ACCESS_KEY=
 MINIO_SECRET_KEY=

+ 38 - 0
docs/acceptance-checklist.md

@@ -0,0 +1,38 @@
+# 验收清单(功能 1–17)
+
+对照《功能建设清单》核心功能项。部署类 18–25 另按现场实施。
+
+## 移动端扫码反馈门户
+
+- [ ] 1. 首页与品牌展示(四大入口、纪检红视觉)
+- [ ] 2. 在线反馈-须知确认(5 秒倒计时)
+- [ ] 3. 在线反馈-表单提交(投诉/表扬、内容必填、附件、匿名)
+- [ ] 4. 建言献策(内容必填;联系人/附件选填)
+- [ ] 5. 监督热线(配置展示、一键拨打)
+- [ ] 6. 扫码分享(二维码弹层;自定义图或落地 URL 提示)
+
+## Web 纪检管理后台
+
+- [ ] 7. 工作台概览(四指标 + 最近反馈)
+- [ ] 8. 在线反馈管理(筛选、详情、状态流转)
+- [ ] 9. 建言献策管理(筛选、详情、状态流转)
+- [ ] 10. 监督热线配置(保存后移动端可见)
+- [ ] 11. 二维码配置(落地链接/文案/自定义图、预览)
+
+## 后端服务与数据
+
+- [ ] 12. 业务接口服务(公开提交 + 管理查询办理)
+- [ ] 13. 附件上传与存储(local/MinIO + attachment 记录)
+- [ ] 14. 数据台账与留痕(结构化存储、办理备注)
+
+## 安全与权限
+
+- [ ] 15. 登录鉴权(JWT 会话)
+- [ ] 16. 权限分级(角色菜单 + 角色授权页;办理/配置写权限)
+- [ ] 17. 操作审计(登录、办理、配置变更写入 oper_log 并可查询)
+
+## 冒烟记录
+
+| 日期 | 环境 | 结果 | 备注 |
+|------|------|------|------|
+|  |  |  |  |

+ 90 - 0
docs/deploy-test.md

@@ -0,0 +1,90 @@
+# 测试环境部署与联调说明
+
+## 1. MySQL
+
+1. 在与 shanxia2.0 **同一测试实例**上创建库(勿写入 shanxia 业务库):
+
+```sql
+CREATE DATABASE IF NOT EXISTS ajyy DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+```
+
+2. 配置 `backend/.env`(可参考 `.env.example`):
+
+```text
+DATABASE_URL=mysql+pymysql://USER:PASS@HOST:3306/ajyy?charset=utf8mb4
+```
+
+3. 首次启动后端会 `create_all` + 幂等种子(角色/菜单/admin/热线/二维码)。
+
+默认管理员:`admin` / `Admin@123`(上线前务必修改)。
+
+## 2. MinIO
+
+与 shanxia 测试环境相同 endpoint/密钥,**使用独立 bucket `ajyy`**:
+
+```text
+STORAGE_TYPE=minio
+MINIO_ENDPOINT=...
+MINIO_ACCESS_KEY=...
+MINIO_SECRET_KEY=...
+MINIO_BUCKET=ajyy
+MINIO_SECURE=false
+MINIO_BASE_URL=   # 可选,对外访问前缀
+```
+
+本地开发可用 `STORAGE_TYPE=local`,文件落在 `LOCAL_STORAGE_DIR`,通过 `/uploads` 静态访问。
+
+## 3. 启动后端
+
+```bash
+cd backend
+python -m venv venv
+source venv/bin/activate   # Windows: venv\Scripts\activate
+pip install -r requirements.txt
+cp .env.example .env       # 按环境填写
+uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+```
+
+健康检查:`GET http://HOST:8000/api/health`
+
+## 4. Web 管理端
+
+```bash
+cd web-admin
+npm install
+npm run dev                # 开发:代理 /api → VITE_API_PROXY_TARGET
+# 或
+npm run build              # 产物在 dist/
+```
+
+生产建议:nginx 托管 `web-admin/dist`,并把 `/api`、`/uploads` 反代到 uvicorn。
+
+示例:
+
+```nginx
+location /api/ {
+  proxy_pass http://127.0.0.1:8000/api/;
+}
+location /uploads/ {
+  proxy_pass http://127.0.0.1:8000/uploads/;
+}
+location / {
+  root /path/to/web-admin/dist;
+  try_files $uri $uri/ /index.html;
+}
+```
+
+## 5. 移动端 H5(uni-app)
+
+1. 用 HBuilderX 打开 `mobile-wx/`
+2. 修改 `mobile-wx/config/api.js` 中 `BASE_URL` 为可访问的后端地址(真机勿用 127.0.0.1)
+3. 运行到浏览器 / 发行 H5,将静态资源部署到 HTTPS 域名
+4. 管理端「二维码配置」中把 `land_url` 设为该 H5 落地地址;可上传自定义二维码图供分享弹层展示
+
+## 6. 最小联调路径
+
+1. 启动 backend + web-admin
+2. 管理端登录 `admin` / `Admin@123`
+3. H5 提交一条「在线反馈」
+4. 管理端「在线反馈」列表可见并办理状态
+5. 修改「热线配置」后,H5「监督热线」刷新可见新电话

+ 4 - 0
mobile-wx/.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+unpackage/
+.hbuilderx/
+.DS_Store

+ 65 - 0
mobile-wx/App.vue

@@ -0,0 +1,65 @@
+<script>
+  export default {
+    onLaunch() {},
+    onShow() {},
+    onHide() {}
+  }
+</script>
+
+<style>
+  page {
+    --primary: #a51f23;
+    --primary-dark: #8a1a1d;
+    --primary-soft: #f8ecec;
+    --gold: #c9a227;
+    --ink: #1f1f1f;
+    --muted: #8a8a8a;
+    --line: #ececec;
+    --bg: #f4f4f4;
+    --card: #ffffff;
+    --radius: 12px;
+    background: var(--bg);
+    color: var(--ink);
+    font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
+    font-size: 28rpx;
+  }
+
+  .card {
+    background: var(--card);
+    border-radius: var(--radius);
+    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+  }
+
+  .btn-primary {
+    background: var(--primary);
+    color: #fff;
+    border-radius: 12rpx;
+    text-align: center;
+    padding: 24rpx;
+    font-size: 30rpx;
+  }
+
+  .btn-primary.disabled {
+    opacity: 0.5;
+  }
+
+  .sec-title {
+    font-size: 28rpx;
+    font-weight: 600;
+    margin-bottom: 16rpx;
+  }
+
+  .field {
+    margin-bottom: 20rpx;
+  }
+
+  .field input,
+  .field textarea {
+    width: 100%;
+    background: #fafafa;
+    border: 1rpx solid var(--line);
+    border-radius: 10rpx;
+    padding: 20rpx;
+    box-sizing: border-box;
+  }
+</style>

+ 17 - 0
mobile-wx/api/public.js

@@ -0,0 +1,17 @@
+import { request } from '../utils/request.js'
+
+export function getHotline() {
+  return request({ url: '/public/hotline' })
+}
+
+export function getQrcode() {
+  return request({ url: '/public/qrcode' })
+}
+
+export function submitFeedback(data) {
+  return request({ url: '/public/feedback', method: 'POST', data })
+}
+
+export function submitSuggestion(data) {
+  return request({ url: '/public/suggestion', method: 'POST', data })
+}

+ 1 - 0
mobile-wx/config/api.js

@@ -0,0 +1 @@
+export const BASE_URL = 'http://127.0.0.1:8000/api'

+ 20 - 0
mobile-wx/index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
+        CSS.supports('top: constant(a)'))
+      document.write(
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
+        (coverSupport ? ', viewport-fit=cover' : '') + '" />')
+    </script>
+    <title></title>
+    <!--preload-links-->
+    <!--app-context-->
+  </head>
+  <body>
+    <div id="app"><!--app-html--></div>
+    <script type="module" src="/main.js"></script>
+  </body>
+</html>

+ 22 - 0
mobile-wx/main.js

@@ -0,0 +1,22 @@
+import App from './App'
+
+// #ifndef VUE3
+import Vue from 'vue'
+import './uni.promisify.adaptor'
+Vue.config.productionTip = false
+App.mpType = 'app'
+const app = new Vue({
+  ...App
+})
+app.$mount()
+// #endif
+
+// #ifdef VUE3
+import { createSSRApp } from 'vue'
+export function createApp() {
+  const app = createSSRApp(App)
+  return {
+    app
+  }
+}
+// #endif

+ 72 - 0
mobile-wx/manifest.json

@@ -0,0 +1,72 @@
+{
+    "name" : "mobile-wx",
+    "appid" : "",
+    "description" : "",
+    "versionName" : "1.0.0",
+    "versionCode" : "100",
+    "transformPx" : false,
+    /* 5+App特有相关 */
+    "app-plus" : {
+        "usingComponents" : true,
+        "nvueStyleCompiler" : "uni-app",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        /* 模块配置 */
+        "modules" : {},
+        /* 应用发布信息 */
+        "distribute" : {
+            /* android打包配置 */
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ]
+            },
+            /* ios打包配置 */
+            "ios" : {},
+            /* SDK配置 */
+            "sdkConfigs" : {}
+        }
+    },
+    /* 快应用特有相关 */
+    "quickapp" : {},
+    /* 小程序特有相关 */
+    "mp-weixin" : {
+        "appid" : "",
+        "setting" : {
+            "urlCheck" : false
+        },
+        "usingComponents" : true
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "uniStatistics" : {
+        "enable" : false
+    },
+    "vueVersion" : "3"
+}

+ 50 - 0
mobile-wx/pages.json

@@ -0,0 +1,50 @@
+{
+  "pages": [
+    {
+      "path": "pages/home/index",
+      "style": {
+        "navigationBarTitleText": "首页",
+        "navigationBarBackgroundColor": "#a51f23",
+        "navigationBarTextStyle": "white"
+      }
+    },
+    {
+      "path": "pages/notice/index",
+      "style": {
+        "navigationBarTitleText": "反馈须知",
+        "navigationBarBackgroundColor": "#a51f23",
+        "navigationBarTextStyle": "white"
+      }
+    },
+    {
+      "path": "pages/feedback/index",
+      "style": {
+        "navigationBarTitleText": "在线反馈",
+        "navigationBarBackgroundColor": "#a51f23",
+        "navigationBarTextStyle": "white"
+      }
+    },
+    {
+      "path": "pages/suggest/index",
+      "style": {
+        "navigationBarTitleText": "建言献策",
+        "navigationBarBackgroundColor": "#a51f23",
+        "navigationBarTextStyle": "white"
+      }
+    },
+    {
+      "path": "pages/hotline/index",
+      "style": {
+        "navigationBarTitleText": "监督热线",
+        "navigationBarBackgroundColor": "#a51f23",
+        "navigationBarTextStyle": "white"
+      }
+    }
+  ],
+  "globalStyle": {
+    "navigationBarTextStyle": "white",
+    "navigationBarTitleText": "安徽建工集团医院纪检室",
+    "navigationBarBackgroundColor": "#a51f23",
+    "backgroundColor": "#f4f4f4"
+  }
+}

+ 131 - 0
mobile-wx/pages/feedback/index.vue

@@ -0,0 +1,131 @@
+<template>
+  <view class="page">
+    <view class="card box">
+      <view class="sec-title">反馈类型</view>
+      <view class="types">
+        <view class="type" :class="{ on: form.fbType === '投诉' }" @click="form.fbType = '投诉'">投诉</view>
+        <view class="type" :class="{ on: form.fbType === '表扬' }" @click="form.fbType = '表扬'">表扬</view>
+      </view>
+
+      <view class="sec-title">被反馈人信息(选填)</view>
+      <view class="field"><input v-model="form.targetName" placeholder="被反馈人姓名" /></view>
+      <view class="field"><input v-model="form.targetDept" placeholder="部门" /></view>
+      <view class="field"><input v-model="form.targetPost" placeholder="职务" /></view>
+
+      <view class="sec-title">反馈内容</view>
+      <view class="field">
+        <textarea v-model="form.content" placeholder="请客观具体描述" maxlength="2000" />
+      </view>
+
+      <view class="sec-title">反馈材料(选填)</view>
+      <view class="uploads">
+        <image v-for="(u, i) in form.attachmentUrls" :key="i" :src="u" class="thumb" mode="aspectFill" />
+        <view class="add" @click="pickFiles">+</view>
+      </view>
+
+      <view class="sec-title">联系方式(选填,可匿名)</view>
+      <view class="field"><input v-model="form.reporterName" placeholder="姓名" /></view>
+      <view class="field"><input v-model="form.reporterPhone" placeholder="手机号" /></view>
+
+      <view class="btn-primary" :class="{ disabled: submitting }" @click="submit">提交反馈</view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { submitFeedback } from '../../api/public.js'
+  import { chooseAndUploadImages } from '../../utils/upload.js'
+
+  export default {
+    data() {
+      return {
+        submitting: false,
+        form: {
+          fbType: '投诉',
+          targetName: '',
+          targetDept: '',
+          targetPost: '',
+          content: '',
+          reporterName: '',
+          reporterPhone: '',
+          attachmentUrls: []
+        }
+      }
+    },
+    methods: {
+      async pickFiles() {
+        try {
+          const urls = await chooseAndUploadImages(6, 'feedback')
+          this.form.attachmentUrls = this.form.attachmentUrls.concat(urls)
+        } catch (e) {}
+      },
+      async submit() {
+        if (!this.form.content || !this.form.content.trim()) {
+          uni.showToast({ title: '请填写反馈内容', icon: 'none' })
+          return
+        }
+        if (this.submitting) return
+        this.submitting = true
+        try {
+          await submitFeedback({ ...this.form })
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.reLaunch({ url: '/pages/home/index' })
+          }, 800)
+        } finally {
+          this.submitting = false
+        }
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .page {
+    padding: 24rpx;
+  }
+  .box {
+    padding: 28rpx;
+  }
+  .types {
+    display: flex;
+    gap: 16rpx;
+    margin-bottom: 24rpx;
+  }
+  .type {
+    flex: 1;
+    text-align: center;
+    padding: 18rpx;
+    border: 1rpx solid var(--line);
+    border-radius: 10rpx;
+  }
+  .type.on {
+    background: var(--primary-soft);
+    border-color: var(--primary);
+    color: var(--primary);
+    font-weight: 600;
+  }
+  textarea {
+    min-height: 180rpx;
+  }
+  .uploads {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 16rpx;
+    margin-bottom: 20rpx;
+  }
+  .thumb,
+  .add {
+    width: 140rpx;
+    height: 140rpx;
+    border-radius: 10rpx;
+  }
+  .add {
+    border: 1rpx dashed #ccc;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 48rpx;
+    color: #999;
+  }
+</style>

+ 157 - 0
mobile-wx/pages/home/index.vue

@@ -0,0 +1,157 @@
+<template>
+  <view class="page">
+    <view class="hero card">
+      <view class="org">安徽建工集团医院纪检室</view>
+      <view class="slogan">扫码监督 · 阳光纪检</view>
+      <view class="tip">扫码即可反馈 · 支持匿名 · 信息严格保密</view>
+    </view>
+
+    <view class="grid">
+      <view class="entry card" @click="goNotice">
+        <view class="title">在线反馈</view>
+        <view class="desc">投诉表扬
+有序反馈</view>
+      </view>
+      <view class="entry card" @click="goSuggest">
+        <view class="title">建言献策</view>
+        <view class="desc">建言献策
+促进完善</view>
+      </view>
+      <view class="entry card" @click="goHotline">
+        <view class="title">监督热线</view>
+        <view class="desc">电话邮箱
+一键联系</view>
+      </view>
+      <view class="entry card" @click="openShare">
+        <view class="title">扫码分享</view>
+        <view class="desc">生成二维码
+转发分享</view>
+      </view>
+    </view>
+
+    <view v-if="shareVisible" class="mask" @click="shareVisible = false">
+      <view class="share-box card" @click.stop>
+        <view class="share-title">{{ qr.title || '安建医院纪检 · 扫码反馈' }}</view>
+        <image v-if="qr.imageUrl" class="qr-img" :src="qr.imageUrl" mode="aspectFit" />
+        <view v-else class="qr-fallback">
+          <view>请使用后台上传自定义二维码图片</view>
+          <view class="url">{{ qr.landUrl || '未配置落地链接' }}</view>
+        </view>
+        <view class="btn-primary" @click="shareVisible = false">关闭</view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { getQrcode } from '../../api/public.js'
+
+  export default {
+    data() {
+      return {
+        shareVisible: false,
+        qr: { title: '', landUrl: '', imageUrl: '' }
+      }
+    },
+    methods: {
+      goNotice() {
+        uni.navigateTo({ url: '/pages/notice/index' })
+      },
+      goSuggest() {
+        uni.navigateTo({ url: '/pages/suggest/index' })
+      },
+      goHotline() {
+        uni.navigateTo({ url: '/pages/hotline/index' })
+      },
+      async openShare() {
+        try {
+          const res = await getQrcode()
+          this.qr = res.data || {}
+        } catch (e) {
+          this.qr = {}
+        }
+        this.shareVisible = true
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .page {
+    padding: 32rpx 28rpx 48rpx;
+  }
+  .hero {
+    padding: 40rpx 32rpx;
+    background: linear-gradient(145deg, #a51f23, #8a1a1d);
+    color: #fff;
+    margin-bottom: 28rpx;
+  }
+  .org {
+    font-size: 34rpx;
+    font-weight: 700;
+  }
+  .slogan {
+    margin-top: 12rpx;
+    color: #f0d78c;
+    font-size: 28rpx;
+  }
+  .tip {
+    margin-top: 18rpx;
+    font-size: 24rpx;
+    opacity: 0.9;
+  }
+  .grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 20rpx;
+  }
+  .entry {
+    padding: 28rpx 24rpx;
+    min-height: 180rpx;
+  }
+  .entry .title {
+    font-size: 30rpx;
+    font-weight: 600;
+    color: var(--primary);
+  }
+  .entry .desc {
+    margin-top: 16rpx;
+    color: var(--muted);
+    font-size: 24rpx;
+    white-space: pre-line;
+    line-height: 1.5;
+  }
+  .mask {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 99;
+  }
+  .share-box {
+    width: 78%;
+    padding: 32rpx;
+    text-align: center;
+  }
+  .share-title {
+    font-weight: 600;
+    margin-bottom: 20rpx;
+  }
+  .qr-img {
+    width: 360rpx;
+    height: 360rpx;
+    margin: 0 auto 24rpx;
+  }
+  .qr-fallback {
+    color: var(--muted);
+    font-size: 24rpx;
+    margin-bottom: 24rpx;
+  }
+  .url {
+    margin-top: 12rpx;
+    word-break: break-all;
+    color: var(--ink);
+  }
+</style>

+ 83 - 0
mobile-wx/pages/hotline/index.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="page">
+    <view class="card box">
+      <view class="title">监督热线</view>
+      <view class="row" @click="call">
+        <view class="label">电话</view>
+        <view class="value link">{{ info.phone || '-' }}</view>
+      </view>
+      <view class="row">
+        <view class="label">邮箱</view>
+        <view class="value">{{ info.email || '-' }}</view>
+      </view>
+      <view class="row">
+        <view class="label">地址</view>
+        <view class="value">{{ info.address || '-' }}</view>
+      </view>
+      <view class="row">
+        <view class="label">受理时间</view>
+        <view class="value">{{ info.workTime || '-' }}</view>
+      </view>
+      <view class="btn-primary" @click="call">一键拨打</view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { getHotline } from '../../api/public.js'
+
+  export default {
+    data() {
+      return {
+        info: { phone: '', email: '', address: '', workTime: '' }
+      }
+    },
+    async onShow() {
+      try {
+        const res = await getHotline()
+        this.info = res.data || {}
+      } catch (e) {}
+    },
+    methods: {
+      call() {
+        if (!this.info.phone) {
+          uni.showToast({ title: '未配置电话', icon: 'none' })
+          return
+        }
+        uni.makePhoneCall({ phoneNumber: String(this.info.phone).replace(/[^\d+]/g, '') })
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .page {
+    padding: 28rpx;
+  }
+  .box {
+    padding: 32rpx;
+  }
+  .title {
+    font-size: 34rpx;
+    font-weight: 700;
+    margin-bottom: 24rpx;
+  }
+  .row {
+    display: flex;
+    padding: 18rpx 0;
+    border-bottom: 1rpx solid var(--line);
+  }
+  .label {
+    width: 140rpx;
+    color: var(--muted);
+  }
+  .value {
+    flex: 1;
+  }
+  .link {
+    color: var(--primary);
+  }
+  .btn-primary {
+    margin-top: 32rpx;
+  }
+</style>

+ 83 - 0
mobile-wx/pages/notice/index.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="page">
+    <view class="card box">
+      <view class="title">在线反馈须知</view>
+      <view class="list">
+        <view>1. 反馈内容应客观具体,尽量写明时间、地点、人物及相关事实。</view>
+        <view>2. 请勿提交涉密信息或不实材料。</view>
+        <view>3. 举报人信息为选填,可匿名反馈;若留联系方式,便于核实回访。</view>
+        <view>4. 纪检室将对反馈信息严格保密,依法依规办理。</view>
+      </view>
+      <view class="hint">温馨提示:反馈类型可选「投诉」或「表扬」。反馈内容为必填项,其他信息均可选填。</view>
+      <view class="count">请仔细阅读须知,<text class="n">{{ seconds }}</text> 秒后进入反馈页面</view>
+      <view class="btn-primary" :class="{ disabled: seconds > 0 }" @click="go">
+        {{ seconds > 0 ? `请阅读(${seconds})` : '已阅读并继续' }}
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    data() {
+      return { seconds: 5, timer: null }
+    },
+    onShow() {
+      this.seconds = 5
+      clearInterval(this.timer)
+      this.timer = setInterval(() => {
+        if (this.seconds <= 1) {
+          this.seconds = 0
+          clearInterval(this.timer)
+          return
+        }
+        this.seconds -= 1
+      }, 1000)
+    },
+    onUnload() {
+      clearInterval(this.timer)
+    },
+    methods: {
+      go() {
+        if (this.seconds > 0) return
+        uni.navigateTo({ url: '/pages/feedback/index' })
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .page {
+    padding: 28rpx;
+  }
+  .box {
+    padding: 32rpx;
+  }
+  .title {
+    font-size: 34rpx;
+    font-weight: 700;
+    margin-bottom: 20rpx;
+  }
+  .list view {
+    color: #444;
+    line-height: 1.7;
+    margin-bottom: 10rpx;
+  }
+  .hint {
+    margin-top: 16rpx;
+    padding: 16rpx;
+    background: var(--primary-soft);
+    color: var(--primary-dark);
+    border-radius: 10rpx;
+    font-size: 24rpx;
+  }
+  .count {
+    margin: 24rpx 0;
+    color: var(--muted);
+    font-size: 24rpx;
+  }
+  .n {
+    color: var(--primary);
+    font-weight: 700;
+  }
+</style>

+ 101 - 0
mobile-wx/pages/suggest/index.vue

@@ -0,0 +1,101 @@
+<template>
+  <view class="page">
+    <view class="card box">
+      <view class="sec-title">建议内容</view>
+      <view class="field">
+        <textarea v-model="form.content" placeholder="请填写建言内容" maxlength="2000" />
+      </view>
+      <view class="sec-title">联系人信息(选填)</view>
+      <view class="field"><input v-model="form.contactName" placeholder="姓名" /></view>
+      <view class="field"><input v-model="form.contactUnit" placeholder="单位" /></view>
+      <view class="field"><input v-model="form.contactPost" placeholder="职务" /></view>
+      <view class="field"><input v-model="form.contactPhone" placeholder="联系方式" /></view>
+
+      <view class="sec-title">附件(选填)</view>
+      <view class="uploads">
+        <image v-for="(u, i) in form.attachmentUrls" :key="i" :src="u" class="thumb" mode="aspectFill" />
+        <view class="add" @click="pickFiles">+</view>
+      </view>
+
+      <view class="btn-primary" :class="{ disabled: submitting }" @click="submit">提交建议</view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import { submitSuggestion } from '../../api/public.js'
+  import { chooseAndUploadImages } from '../../utils/upload.js'
+
+  export default {
+    data() {
+      return {
+        submitting: false,
+        form: {
+          content: '',
+          contactName: '',
+          contactUnit: '',
+          contactPost: '',
+          contactPhone: '',
+          attachmentUrls: []
+        }
+      }
+    },
+    methods: {
+      async pickFiles() {
+        try {
+          const urls = await chooseAndUploadImages(6, 'suggestion')
+          this.form.attachmentUrls = this.form.attachmentUrls.concat(urls)
+        } catch (e) {}
+      },
+      async submit() {
+        if (!this.form.content || !this.form.content.trim()) {
+          uni.showToast({ title: '请填写建议内容', icon: 'none' })
+          return
+        }
+        if (this.submitting) return
+        this.submitting = true
+        try {
+          await submitSuggestion({ ...this.form })
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.reLaunch({ url: '/pages/home/index' })
+          }, 800)
+        } finally {
+          this.submitting = false
+        }
+      }
+    }
+  }
+</script>
+
+<style scoped>
+  .page {
+    padding: 24rpx;
+  }
+  .box {
+    padding: 28rpx;
+  }
+  textarea {
+    min-height: 200rpx;
+  }
+  .uploads {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 16rpx;
+    margin-bottom: 20rpx;
+  }
+  .thumb,
+  .add {
+    width: 140rpx;
+    height: 140rpx;
+    border-radius: 10rpx;
+  }
+  .add {
+    border: 1rpx dashed #ccc;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 48rpx;
+    color: #999;
+  }
+</style>

BIN
mobile-wx/static/logo.png


+ 13 - 0
mobile-wx/uni.promisify.adaptor.js

@@ -0,0 +1,13 @@
+uni.addInterceptor({
+  returnValue (res) {
+    if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
+      return res;
+    }
+    return new Promise((resolve, reject) => {
+      res.then((res) => {
+        if (!res) return resolve(res) 
+        return res[0] ? reject(res[0]) : resolve(res[1])
+      });
+    });
+  },
+});

+ 76 - 0
mobile-wx/uni.scss

@@ -0,0 +1,76 @@
+/**
+ * 这里是uni-app内置的常用样式变量
+ *
+ * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
+ * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
+ *
+ */
+
+/**
+ * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
+ *
+ * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
+ */
+
+/* 颜色变量 */
+
+/* 行为相关颜色 */
+$uni-color-primary: #007aff;
+$uni-color-success: #4cd964;
+$uni-color-warning: #f0ad4e;
+$uni-color-error: #dd524d;
+
+/* 文字基本颜色 */
+$uni-text-color:#333;//基本色
+$uni-text-color-inverse:#fff;//反色
+$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
+$uni-text-color-placeholder: #808080;
+$uni-text-color-disable:#c0c0c0;
+
+/* 背景颜色 */
+$uni-bg-color:#ffffff;
+$uni-bg-color-grey:#f8f8f8;
+$uni-bg-color-hover:#f1f1f1;//点击状态颜色
+$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
+
+/* 边框颜色 */
+$uni-border-color:#c8c7cc;
+
+/* 尺寸变量 */
+
+/* 文字尺寸 */
+$uni-font-size-sm:12px;
+$uni-font-size-base:14px;
+$uni-font-size-lg:16px;
+
+/* 图片尺寸 */
+$uni-img-size-sm:20px;
+$uni-img-size-base:26px;
+$uni-img-size-lg:40px;
+
+/* Border Radius */
+$uni-border-radius-sm: 2px;
+$uni-border-radius-base: 3px;
+$uni-border-radius-lg: 6px;
+$uni-border-radius-circle: 50%;
+
+/* 水平间距 */
+$uni-spacing-row-sm: 5px;
+$uni-spacing-row-base: 10px;
+$uni-spacing-row-lg: 15px;
+
+/* 垂直间距 */
+$uni-spacing-col-sm: 4px;
+$uni-spacing-col-base: 8px;
+$uni-spacing-col-lg: 12px;
+
+/* 透明度 */
+$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
+
+/* 文章场景相关 */
+$uni-color-title: #2C405A; // 文章标题颜色
+$uni-font-size-title:20px;
+$uni-color-subtitle: #555555; // 二级标题颜色
+$uni-font-size-subtitle:26px;
+$uni-color-paragraph: #3F536E; // 文章段落颜色
+$uni-font-size-paragraph:15px;

+ 30 - 0
mobile-wx/utils/request.js

@@ -0,0 +1,30 @@
+import { BASE_URL } from '../config/api.js'
+
+export function request(options = {}) {
+  const { url, method = 'GET', data, header = {} } = options
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: `${BASE_URL}${url}`,
+      method,
+      data,
+      header: {
+        'Content-Type': 'application/json',
+        ...header
+      },
+      success: (res) => {
+        const body = res.data || {}
+        if (res.statusCode >= 200 && res.statusCode < 300 && body.code === 200) {
+          resolve(body)
+          return
+        }
+        const msg = body.message || `请求失败(${res.statusCode})`
+        uni.showToast({ title: msg, icon: 'none' })
+        reject(body)
+      },
+      fail: (err) => {
+        uni.showToast({ title: '网络异常', icon: 'none' })
+        reject(err)
+      }
+    })
+  })
+}

+ 47 - 0
mobile-wx/utils/upload.js

@@ -0,0 +1,47 @@
+import { BASE_URL } from '../config/api.js'
+
+export function uploadFile(filePath, folder = 'feedback') {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: `${BASE_URL}/public/attachment/upload`,
+      filePath,
+      name: 'file',
+      formData: { folder },
+      success: (res) => {
+        try {
+          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+          if (body.code === 200) {
+            resolve(body.data)
+            return
+          }
+          uni.showToast({ title: body.message || '上传失败', icon: 'none' })
+          reject(body)
+        } catch (e) {
+          reject(e)
+        }
+      },
+      fail: reject
+    })
+  })
+}
+
+export function chooseAndUploadImages(count = 3, folder = 'feedback') {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count,
+      success: async (res) => {
+        try {
+          const urls = []
+          for (const path of res.tempFilePaths || []) {
+            const data = await uploadFile(path, folder)
+            if (data?.url) urls.push(data.url)
+          }
+          resolve(urls)
+        } catch (e) {
+          reject(e)
+        }
+      },
+      fail: reject
+    })
+  })
+}