Browse Source

PC 原型 Vue 3 升级版上线 pc-v3,并补充 PRD 文档与本地权限

- pc-v3/:基于 Vite + Vue 3 + Element Plus + @vueup/vue-quill 全量迁移的并行版本
- 修正 PRD 汇丰银行权益中台目录下的文档(删除/新增/更新)
- .claude/settings.local.json:放行迁移期间用到的 grep 命令
Fade 1 month ago
parent
commit
760ab901a2
99 changed files with 21568 additions and 397 deletions
  1. 2 1
      .claude/settings.local.json
  2. 8 0
      pc-v3/.claude/settings.local.json
  3. 24 0
      pc-v3/.gitignore
  4. 91 0
      pc-v3/CLAUDE.md
  5. 33 0
      pc-v3/README.md
  6. 17 0
      pc-v3/index.html
  7. 19 0
      pc-v3/jsconfig.json
  8. 3417 0
      pc-v3/package-lock.json
  9. 29 0
      pc-v3/package.json
  10. BIN
      pc-v3/public/favicon.ico
  11. 208 0
      pc-v3/scripts/MIGRATION_RULES.md
  12. 40 0
      pc-v3/scripts/capture-one.js
  13. 120 0
      pc-v3/scripts/capture-screenshots.js
  14. 171 0
      pc-v3/scripts/capture-supplements.js
  15. 32 0
      pc-v3/scripts/dom-probe.mjs
  16. 28 0
      pc-v3/scripts/dom-probe2.mjs
  17. 25 0
      pc-v3/scripts/probe-channel.mjs
  18. BIN
      pc-v3/scripts/screenshot-equity-products.png
  19. 33 0
      pc-v3/scripts/screenshot.mjs
  20. BIN
      pc-v3/scripts/shot-add-equity.png
  21. BIN
      pc-v3/scripts/shot-channel-dialog.png
  22. 17 0
      pc-v3/scripts/shot-channel.mjs
  23. BIN
      pc-v3/scripts/shot-crowd-tasks.png
  24. BIN
      pc-v3/scripts/shot-equity.png
  25. BIN
      pc-v3/scripts/shot-grant-campaign.png
  26. BIN
      pc-v3/scripts/shot-page-home.png
  27. BIN
      pc-v3/scripts/shot-quill-final.png
  28. BIN
      pc-v3/scripts/shot-quill-fixed.png
  29. BIN
      pc-v3/scripts/shot-quill-fixed2.png
  30. 21 0
      pc-v3/scripts/shot-quill.mjs
  31. 26 0
      pc-v3/scripts/shot-quill2.mjs
  32. 19 0
      pc-v3/scripts/shot-quill3.mjs
  33. BIN
      pc-v3/scripts/shot-rule-groups.png
  34. BIN
      pc-v3/scripts/shot-sms-templates.png
  35. BIN
      pc-v3/scripts/shot-tags.png
  36. 32 0
      pc-v3/scripts/smoke.mjs
  37. 14 0
      pc-v3/src/App.vue
  38. BIN
      pc-v3/src/assets/logo.png
  39. 142 0
      pc-v3/src/components/EquityLinkDialog.vue
  40. 58 0
      pc-v3/src/components/HelloWorld.vue
  41. 252 0
      pc-v3/src/components/Layout.vue
  42. 120 0
      pc-v3/src/components/RongshuProductDialog.vue
  43. 91 0
      pc-v3/src/components/ShowPagesDialog.vue
  44. 123 0
      pc-v3/src/components/crowdNameList/AdjustMemberDialog.vue
  45. 205 0
      pc-v3/src/components/crowdNameList/UploadAdjustDialog.vue
  46. 135 0
      pc-v3/src/components/crowdTask/AddRuleGroupDialog.vue
  47. 43 0
      pc-v3/src/components/crowdTask/SampleDetailDialog.vue
  48. 272 0
      pc-v3/src/components/ruleGroup/RuleEditDialog.vue
  49. 547 0
      pc-v3/src/components/tag/RuleBuilder.vue
  50. 200 0
      pc-v3/src/config/metaRules.js
  51. 297 0
      pc-v3/src/config/tagParams.js
  52. 22 0
      pc-v3/src/main.js
  53. 1026 0
      pc-v3/src/mock/index.js
  54. 271 0
      pc-v3/src/router/index.js
  55. 129 0
      pc-v3/src/store/crowdNameListStore.js
  56. 191 0
      pc-v3/src/store/crowdTaskStore.js
  57. 163 0
      pc-v3/src/store/ruleGroupStore.js
  58. 87 0
      pc-v3/src/store/smsTemplateStore.js
  59. 184 0
      pc-v3/src/store/tagStore.js
  60. 21 0
      pc-v3/src/styles/quill.css
  61. 19 0
      pc-v3/src/utils/linkOptions.js
  62. 33 0
      pc-v3/src/utils/request.js
  63. 1004 0
      pc-v3/src/views/AddEquityProduct.vue
  64. 548 0
      pc-v3/src/views/AuditDetail.vue
  65. 778 0
      pc-v3/src/views/AuditEquityProduct.vue
  66. 167 0
      pc-v3/src/views/AuditList.vue
  67. 149 0
      pc-v3/src/views/CampaignManagement.vue
  68. 234 0
      pc-v3/src/views/CashDiscountGrant.vue
  69. 319 0
      pc-v3/src/views/ChannelManagement.vue
  70. 355 0
      pc-v3/src/views/CreditCardEquity.vue
  71. 279 0
      pc-v3/src/views/CrowdNameListEdit.vue
  72. 283 0
      pc-v3/src/views/CrowdTaskEdit.vue
  73. 334 0
      pc-v3/src/views/CrowdTaskList.vue
  74. 191 0
      pc-v3/src/views/CrowdTaskView.vue
  75. 259 0
      pc-v3/src/views/CustomerEquityGrant.vue
  76. 1002 0
      pc-v3/src/views/EditEquityProduct.vue
  77. 214 0
      pc-v3/src/views/EquityGrantCampaign.vue
  78. 378 0
      pc-v3/src/views/EquityGrantCampaignEdit.vue
  79. 178 0
      pc-v3/src/views/EquityGrantCampaignView.vue
  80. 520 0
      pc-v3/src/views/EquityProductCoupons.vue
  81. 466 0
      pc-v3/src/views/EquityProducts.vue
  82. 136 0
      pc-v3/src/views/Login.vue
  83. 764 0
      pc-v3/src/views/PageAssembly.vue
  84. 415 0
      pc-v3/src/views/PageHomeConfig.vue
  85. 24 0
      pc-v3/src/views/PageManagement.vue
  86. 626 0
      pc-v3/src/views/PageZoneConfig.vue
  87. 177 0
      pc-v3/src/views/PointsDetail.vue
  88. 302 0
      pc-v3/src/views/RuleGroupEdit.vue
  89. 180 0
      pc-v3/src/views/RuleGroupList.vue
  90. 130 0
      pc-v3/src/views/RuleGroupView.vue
  91. 237 0
      pc-v3/src/views/SmsSendRecord.vue
  92. 267 0
      pc-v3/src/views/SmsTemplateList.vue
  93. 263 0
      pc-v3/src/views/TagEdit.vue
  94. 206 0
      pc-v3/src/views/TagList.vue
  95. 106 0
      pc-v3/src/views/TagView.vue
  96. 854 0
      pc-v3/src/views/ViewEquityProduct.vue
  97. 21 0
      pc-v3/vite.config.js
  98. 9 283
      prd/汇丰银行/汇丰借记卡权益移动端.md
  99. 136 113
      prd/汇丰银行/汇丰银行权益中台-FSD.md

+ 2 - 1
.claude/settings.local.json

@@ -19,7 +19,8 @@
       "Bash(git add *)",
       "Bash(git commit -m ' *)",
       "Bash(python3 -c ' *)",
-      "Bash(python3)"
+      "Bash(python3)",
+      "Bash(grep -rohE '\\\\$message\\\\.|\\\\$confirm|\\\\$alert|\\\\$prompt|\\\\$msgbox|\\\\$notify' .)"
     ],
     "additionalDirectories": []
   }

+ 8 - 0
pc-v3/.claude/settings.local.json

@@ -0,0 +1,8 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(vue create:*)",
+      "Bash(printf \"y\\\\n\")"
+    ]
+  }
+}

+ 24 - 0
pc-v3/.gitignore

@@ -0,0 +1,24 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.vercel

+ 91 - 0
pc-v3/CLAUDE.md

@@ -0,0 +1,91 @@
+# [CLAUDE.md](http://CLAUDE.md)
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 语言约束
+
+本项目默认使用简体中文输出(界面文案、注释、文档),技术名词(API、URL、ID 等)可保留英文。
+
+## 常用命令
+
+```bash
+npm install        # 安装依赖
+npm run dev        # 启动 Vite 开发服务器(端口 8080,开启文件轮询监听)
+npm run build      # 生产构建
+npm run preview    # 预览生产构建
+npm run lint       # ESLint 校验并自动修复
+```
+
+本项目没有测试框架,未配置单测命令。
+
+## 技术栈
+
+-   Vue 3.5 + Vue Router 4 + Element Plus 2.10
+
+-   Axios 封装请求;Mock.js 拦截 `/api/*` 实现前端假数据
+
+-   @vueup/vue-quill 富文本编辑(替换原 vue-quill-editor)
+
+-   Vite 5 构建,开发服务器开启 `usePolling` 文件监听(适配 Docker/WSL 等场景)
+
+> 本目录是 `pc/`(Vue 2.6 + Element UI 2.15)的 Vue 3 升级版本,二者并行存在;后续新功能开发以本目录为准。
+
+## 整体架构
+
+这是一个 PM(产品经理)用于快速搭建管理后台 Demo 的 Vue 项目,**不连接真实后端**,所有数据通过 Mock.js 在浏览器内拦截生成。
+
+### 启动流程
+
+`src/main.js` 通过 `createApp()` 创建应用,注册 Element Plus(中文 locale)、全量 Element Plus Icons、@vueup/vue-quill、router 和 `src/mock`(重要:导入 mock 模块即注册所有 `Mock.mock(...)` 拦截器,删除此行将导致接口全部 404)。
+
+### 路由与布局(认证骨架)
+
+-   `src/router/index.js` 用 `createRouter` + `createWebHashHistory` 定义全部路由,`beforeEach` 检查 `localStorage.token`,无 token 跳 `/login`
+
+-   `/login` 是独立路由;其余页面全部作为 `Layout.vue` 的 children 渲染在 `<router-view />` 中
+
+-   `Layout.vue` 承担侧边栏菜单 + 顶部面包屑 + 用户下拉。**新增页面 = 在 router 里加一条 children + 在 Layout 的** `<el-menu>` **里加对应菜单项**;面包屑标题直接取 `route.meta.title`
+
+### 请求层
+
+-   `src/utils/request.js` 统一封装 axios:
+
+    -   请求拦截自动带 `Authorization: Bearer <token>`
+
+    -   响应拦截约定 `res.data.code === 200` 视为成功,否则 `ElMessage.error` 并 reject
+
+    -   401 自动清 token 并跳登录
+
+-   所有 Mock 返回体必须遵循 `{ code, data, msg }` 结构,否则会被响应拦截器判为失败
+
+### Mock 数据
+
+-   `src/mock/index.js` 用 `Mock.mock(url, method, handler)` 注册接口。新增后端接口时在这里加一条拦截
+
+-   正则形式 `/\/api\/foo/` 可匹配带 query 的 URL
+
+### 状态共享
+
+-   `src/store/*.js` 使用 Vue 3 `reactive()` 暴露内存状态(仅 Demo 用,刷新即重置)
+
+### Element Plus 使用约定
+
+-   图标统一用 `<el-icon><Plus /></el-icon>`,所有 `@element-plus/icons-vue` 图标已在 `main.js` 全局注册,**模板里直接当组件名用即可,无需逐文件 import**
+
+-   `el-dialog` / `el-drawer` 等用 `v-model="visible"`,不再用 `:visible.sync`
+
+-   `el-submenu` 改名为 `el-sub-menu`
+
+-   `slot="xxx"` 改成 `<template #xxx>`;`slot-scope="scope"` 改成 `<template #default="scope">`
+
+-   `size="mini"` → `size="small"`;`size="medium"` 移除(用默认)
+
+-   消息 / 确认弹窗:`this.$message.xxx()` → `ElMessage.xxx()`;`this.$confirm(...)` → `ElMessageBox.confirm(...)`
+
+### 定制化模块约定
+
+路由里已有 `customization/hsbc/equity-products` 这类命名,表示"定制客户/功能"的层级结构。新增定制页面遵循 `customization/<客户>/<模块>` 的路径前缀,便于区分通用功能与客户定制功能。
+
+## 父目录上下文
+
+本项目位于 `pm_agent/` 工作空间下的 `pc-v3/` 目录,是整个 PM 工作流(需求分析 → PRD → PC Demo)的最后一环。父级 `CLAUDE.md` 定义了 `pc-demo` skill,当用户说"做一个后台页面/管理界面/添加菜单"等触发词时会进入此项目开发流程。

+ 33 - 0
pc-v3/README.md

@@ -0,0 +1,33 @@
+## Project setup
+
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+
+```
+npm run build
+```
+
+### Lints and fixes files
+
+```
+npm run lint
+```
+
+### Customize configuration
+
+See [Configuration Reference](https://cli.vuejs.org/config/).
+
+<br>
+
+<br>
+
+<br>

+ 17 - 0
pc-v3/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+    <link rel="icon" href="/favicon.ico" />
+    <title>管理后台</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>本应用需要启用 JavaScript 才能运行</strong>
+    </noscript>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 19 - 0
pc-v3/jsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "module": "esnext",
+    "baseUrl": "./",
+    "moduleResolution": "node",
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  }
+}

+ 3417 - 0
pc-v3/package-lock.json

@@ -0,0 +1,3417 @@
+{
+  "name": "liso-v3",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "liso-v3",
+      "version": "0.1.0",
+      "dependencies": {
+        "@element-plus/icons-vue": "^2.3.1",
+        "@vueup/vue-quill": "^1.2.0",
+        "axios": "^1.15.0",
+        "element-plus": "^2.10.7",
+        "mockjs": "^1.1.0",
+        "vue": "^3.5.0",
+        "vue-router": "^4.4.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.1.0",
+        "eslint": "^9.10.0",
+        "eslint-plugin-vue": "^9.28.0",
+        "playwright": "^1.60.0",
+        "vite": "^5.4.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.3",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
+      "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.9.1",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+      "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.2",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.21.2",
+      "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz",
+      "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.7",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.5"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz",
+      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.3.5",
+      "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+      "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^6.14.0",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.1",
+        "minimatch": "^3.1.5",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.39.4",
+      "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz",
+      "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.7",
+      "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT"
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz",
+      "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/types": "^0.15.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.8",
+      "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz",
+      "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/core": "^0.19.2",
+        "@humanfs/types": "^0.15.0",
+        "@humanwhocodes/retry": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/types": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz",
+      "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+      "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+      "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+      "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+      "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+      "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+      "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+      "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+      "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+      "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+      "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+      "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+      "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+      "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+      "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+      "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+      "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+      "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+      "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+      "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+      "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+      "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+      "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+      "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.24",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+      "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.34",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+      "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+      "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.34",
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.14",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+      "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz",
+      "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+      "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+      "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/runtime-core": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+      "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "vue": "3.5.34"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+      "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+      "license": "MIT"
+    },
+    "node_modules/@vueup/vue-quill": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/@vueup/vue-quill/-/vue-quill-1.2.0.tgz",
+      "integrity": "sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==",
+      "license": "MIT",
+      "dependencies": {
+        "quill": "^1.3.7",
+        "quill-delta": "^4.2.2"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.41"
+      }
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
+      "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.3.0",
+        "@vueuse/shared": "14.3.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
+      "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
+      "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
+      "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz",
+      "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.5",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.14",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz",
+      "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.9.tgz",
+      "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "get-intrinsic": "^1.3.0",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/clone": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/clone/-/clone-2.1.2.tgz",
+      "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "14.0.3",
+      "resolved": "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz",
+      "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-equal": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-1.1.2.tgz",
+      "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-arguments": "^1.1.1",
+        "is-date-object": "^1.0.5",
+        "is-regex": "^1.1.4",
+        "object-is": "^1.1.5",
+        "object-keys": "^1.1.1",
+        "regexp.prototype.flags": "^1.5.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.0.1",
+        "has-property-descriptors": "^1.0.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.0.tgz",
+      "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.0.1",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+        "@types/lodash": "^4.17.24",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "14.3.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.20",
+        "lodash": "^4.18.1",
+        "lodash-es": "^4.18.1",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.2.8"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.7"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.39.4",
+      "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz",
+      "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.8.0",
+        "@eslint-community/regexpp": "^4.12.1",
+        "@eslint/config-array": "^0.21.2",
+        "@eslint/config-helpers": "^0.4.2",
+        "@eslint/core": "^0.17.0",
+        "@eslint/eslintrc": "^3.3.5",
+        "@eslint/js": "9.39.4",
+        "@eslint/plugin-kit": "^0.4.1",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "ajv": "^6.14.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.5",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-vue": {
+      "version": "9.33.0",
+      "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz",
+      "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "globals": "^13.24.0",
+        "natural-compare": "^1.4.0",
+        "nth-check": "^2.1.1",
+        "postcss-selector-parser": "^6.0.15",
+        "semver": "^7.6.3",
+        "vue-eslint-parser": "^9.4.3",
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-vue/node_modules/globals": {
+      "version": "13.24.0",
+      "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.15.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz",
+      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-2.0.3.tgz",
+      "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+      "license": "MIT"
+    },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "license": "MIT"
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-diff": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.2.0.tgz",
+      "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.4.2",
+      "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz",
+      "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/functions-have-names": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz",
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/is-arguments": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz",
+      "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz",
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "license": "MIT"
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
+      "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mockjs": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/mockjs/-/mockjs-1.1.0.tgz",
+      "integrity": "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==",
+      "dependencies": {
+        "commander": "*"
+      },
+      "bin": {
+        "random": "bin/random"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "node_modules/object-is": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmmirror.com/object-is/-/object-is-1.1.6.tgz",
+      "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parchment": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz",
+      "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/quill": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmmirror.com/quill/-/quill-1.3.7.tgz",
+      "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "clone": "^2.1.1",
+        "deep-equal": "^1.0.1",
+        "eventemitter3": "^2.0.3",
+        "extend": "^3.0.2",
+        "parchment": "^1.1.4",
+        "quill-delta": "^3.6.2"
+      }
+    },
+    "node_modules/quill-delta": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-4.2.2.tgz",
+      "integrity": "sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-diff": "1.2.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.isequal": "^4.5.0"
+      }
+    },
+    "node_modules/quill/node_modules/fast-diff": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.1.2.tgz",
+      "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/quill/node_modules/quill-delta": {
+      "version": "3.6.3",
+      "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-3.6.3.tgz",
+      "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+      "license": "MIT",
+      "dependencies": {
+        "deep-equal": "^1.0.1",
+        "extend": "^3.0.2",
+        "fast-diff": "1.1.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-errors": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz",
+      "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.4",
+        "@rollup/rollup-android-arm64": "4.60.4",
+        "@rollup/rollup-darwin-arm64": "4.60.4",
+        "@rollup/rollup-darwin-x64": "4.60.4",
+        "@rollup/rollup-freebsd-arm64": "4.60.4",
+        "@rollup/rollup-freebsd-x64": "4.60.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+        "@rollup/rollup-linux-arm64-musl": "4.60.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+        "@rollup/rollup-linux-loong64-musl": "4.60.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-musl": "4.60.4",
+        "@rollup/rollup-openbsd-x64": "4.60.4",
+        "@rollup/rollup-openharmony-arm64": "4.60.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+        "@rollup/rollup-win32-x64-gnu": "4.60.4",
+        "@rollup/rollup-win32-x64-msvc": "4.60.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rollup/node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.0",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz",
+      "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-function-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz",
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "functions-have-names": "^1.2.3",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "license": "(MIT OR CC0-1.0)",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz",
+      "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-sfc": "3.5.34",
+        "@vue/runtime-dom": "3.5.34",
+        "@vue/server-renderer": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.1.tgz",
+      "integrity": "sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==",
+      "license": "MIT"
+    },
+    "node_modules/vue-eslint-parser": {
+      "version": "9.4.3",
+      "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
+      "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.4",
+        "eslint-scope": "^7.1.1",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.6"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=6.0.0"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/vue-eslint-parser/node_modules/espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  }
+}

+ 29 - 0
pc-v3/package.json

@@ -0,0 +1,29 @@
+{
+  "name": "liso-v3",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "serve": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "lint": "eslint --ext .js,.vue src --fix"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
+    "@vueup/vue-quill": "^1.2.0",
+    "axios": "^1.15.0",
+    "element-plus": "^2.10.7",
+    "mockjs": "^1.1.0",
+    "vue": "^3.5.0",
+    "vue-router": "^4.4.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.1.0",
+    "eslint": "^9.10.0",
+    "eslint-plugin-vue": "^9.28.0",
+    "playwright": "^1.60.0",
+    "vite": "^5.4.0"
+  }
+}

BIN
pc-v3/public/favicon.ico


+ 208 - 0
pc-v3/scripts/MIGRATION_RULES.md

@@ -0,0 +1,208 @@
+# Vue 2 + Element UI → Vue 3 + Element Plus 迁移规则
+
+适用于 `pc-v3/src/` 下的 `.vue` 文件。逐文件应用以下机械转换。
+
+## 1. 模板(template)转换
+
+### 1.1 组件改名
+- `<el-submenu>` → `<el-sub-menu>`(开闭标签都要改)
+
+### 1.2 `.sync` 修饰符
+- `:visible.sync="x"` → `v-model="x"`
+- `:any-prop.sync="x"` → `v-model:any-prop="x"`
+
+### 1.3 `.native` 修饰符(Vue 3 已移除)
+- `@click.native` → `@click`
+- `@keyup.enter.native` → `@keyup.enter`
+- `@submit.native.prevent` → `@submit.prevent`
+- 其它一切 `.native` 全部删除该修饰符
+
+### 1.4 slot 语法
+- `slot-scope="scope"` → 改成 `<template #default="scope">` 包装(如果原本已在 `<template>` 上则直接换属性)
+- 子元素上的 `slot="header"` → 抽出来包成 `<template #header>...</template>`
+- 子元素上的 `slot="reference"` → 包成 `<template #reference>...</template>`
+- 子元素上的 `slot="dropdown"` → 包成 `<template #dropdown>...</template>`
+- 子元素上的 `slot="prefix"` / `slot="suffix"` / `slot="append"` / `slot="prepend"` → 同上
+
+举例:
+```vue
+<!-- 旧 -->
+<el-popover ...>
+  <div>内容</div>
+  <el-button slot="reference">触发</el-button>
+</el-popover>
+
+<!-- 新 -->
+<el-popover ...>
+  <div>内容</div>
+  <template #reference>
+    <el-button>触发</el-button>
+  </template>
+</el-popover>
+```
+
+```vue
+<!-- 旧 -->
+<el-table-column>
+  <template slot-scope="scope">
+    <span>{{ scope.row.name }}</span>
+  </template>
+</el-table-column>
+
+<!-- 新 -->
+<el-table-column>
+  <template #default="scope">
+    <span>{{ scope.row.name }}</span>
+  </template>
+</el-table-column>
+```
+
+### 1.5 尺寸 size
+- `size="mini"` → `size="small"`
+- `size="medium"` → 删除该属性(用默认)
+
+### 1.6 图标(核心规则!)
+
+主入口 `main.js` 已经把 `@element-plus/icons-vue` 的所有图标**全局注册**,所以模板里直接用组件名即可,**不需要在每个文件 import**。
+
+#### 1.6.1 `<i class="el-icon-xxx"></i>` → `<el-icon><Xxx /></el-icon>`
+
+按下表对应替换。原 `<i>` 标签上的 class(除 el-icon-xxx 外的)、`@click`、`style` 等属性,全部搬到 `<el-icon>` 上:
+
+| 旧 class | 新组件名 |
+|---|---|
+| el-icon-monitor | Monitor |
+| el-icon-setting | Setting |
+| el-icon-s-tools | Tools |
+| el-icon-s-fold | Fold |
+| el-icon-s-unfold | Expand |
+| el-icon-arrow-down | ArrowDown |
+| el-icon-arrow-left | ArrowLeft |
+| el-icon-arrow-right | ArrowRight |
+| el-icon-user | User |
+| el-icon-user-solid | UserFilled |
+| el-icon-lock | Lock |
+| el-icon-plus | Plus |
+| el-icon-edit | Edit |
+| el-icon-edit-outline | EditPen |
+| el-icon-delete | Delete |
+| el-icon-view | View |
+| el-icon-check | Check |
+| el-icon-close | Close |
+| el-icon-circle-close | CircleClose |
+| el-icon-circle-plus-outline | CirclePlus |
+| el-icon-bank-card | CreditCard |
+| el-icon-goods | Goods |
+| el-icon-collection-tag | CollectionTag |
+| el-icon-copy-document | CopyDocument |
+| el-icon-data-board | DataBoard |
+| el-icon-document | Document |
+| el-icon-document-copy | DocumentCopy |
+| el-icon-download | Download |
+| el-icon-files | Files |
+| el-icon-finished | Finished |
+| el-icon-folder-opened | FolderOpened |
+| el-icon-info | InfoFilled |
+| el-icon-link | Link |
+| el-icon-message | Message |
+| el-icon-more | More |
+| el-icon-notebook-2 | Notebook |
+| el-icon-picture | Picture |
+| el-icon-picture-outline | PictureRounded |
+| el-icon-postcard | Postcard |
+| el-icon-present | Present |
+| el-icon-question | QuestionFilled |
+| el-icon-s-data | DataAnalysis |
+| el-icon-s-finance | Money |
+| el-icon-s-grid | Grid |
+| el-icon-s-order | List |
+| el-icon-set-up | SetUp |
+| el-icon-sort | Sort |
+| el-icon-thumb | Pointer |
+| el-icon-upload | Upload |
+| el-icon-video-play | VideoPlay |
+
+举例:
+```vue
+<!-- 旧 -->
+<i class="el-icon-plus"></i>
+<i class="el-icon-edit" style="color:red" @click="x"></i>
+
+<!-- 新 -->
+<el-icon><Plus /></el-icon>
+<el-icon style="color:red" @click="x"><Edit /></el-icon>
+```
+
+#### 1.6.2 组件属性形式 `icon="el-icon-xxx"` / `prefix-icon="el-icon-xxx"`
+
+用 `#prefix`/`#suffix` 等 slot 替代(保持模板内一致,不需要 import):
+
+```vue
+<!-- 旧 -->
+<el-button type="primary" icon="el-icon-plus">添加</el-button>
+<el-input prefix-icon="el-icon-user" v-model="x" />
+
+<!-- 新 -->
+<el-button type="primary">
+  <el-icon><Plus /></el-icon>添加
+</el-button>
+<el-input v-model="x">
+  <template #prefix><el-icon><User /></el-icon></template>
+</el-input>
+```
+
+对于 `<el-button type="text" icon="el-icon-more">`(按钮内只有图标,无文字)也按上面处理:
+```vue
+<el-button type="text">
+  <el-icon><More /></el-icon>
+</el-button>
+```
+
+### 1.7 el-button text 类型
+- `type="text"` 在 Element Plus 仍然支持但已 deprecated,**保持不变**(不要主动改成 link,免得动到样式)
+
+## 2. script 部分转换
+
+### 2.1 全局消息
+- `this.$message(...)` 或 `this.$message.success/info/warning/error(...)`
+- → 顶部加 `import { ElMessage } from 'element-plus'`,调用 `ElMessage(...)` / `ElMessage.success(...)`
+
+- `this.$confirm(...)` → `ElMessageBox.confirm(...)`
+- `this.$alert(...)` → `ElMessageBox.alert(...)`
+- `this.$prompt(...)` → `ElMessageBox.prompt(...)`
+- → 顶部加 `import { ElMessageBox } from 'element-plus'`
+
+如果同一个文件同时用到 message + messagebox,合并成一个 import:
+```js
+import { ElMessage, ElMessageBox } from 'element-plus'
+```
+
+### 2.2 ref 名称
+- 如果 `ref="form"` 且 script 里 `this.$refs.form.validate(...)`,建议保留原 ref 名以减少 diff(无需统一改名为 formRef)
+
+### 2.3 Popover doClose
+- `this.$refs.xxx.doClose()` → `this.$refs.xxx.hide()`(Element Plus ElPopover 暴露 hide 方法)
+- 如果是数组 ref(`:ref="`pop-${i}`"`),`Array.isArray(ref) ? ref[0] : ref` 写法保留,只把末尾 `.doClose()` 换成 `.hide()`
+
+### 2.4 emit 声明
+- Vue 3 推荐在组件里声明 emits,但**为了减少改动,本次不强制要求加**,子组件用 `this.$emit('update:visible', false)` 之类继续工作
+
+### 2.5 富文本(vue-quill-editor → @vueup/vue-quill)
+- 移除 `import { quillEditor } from 'vue-quill-editor'`,移除 `components: { quillEditor }`
+- 模板里 `<quill-editor v-model="x" :options="..." />` → `<quill-editor v-model:content="x" content-type="html" :options="quillOptions" />`
+- `QuillEditor` 已在 `main.js` 全局注册(kebab-case:`<quill-editor>`),不必逐文件 import
+
+## 3. 其它
+
+- 顶部 `import` 的 `'element-ui'` 全部替换成 `'element-plus'`
+- `'element-ui/lib/...'` 之类的 CSS / 子模块 import 全部删除(main.js 统一处理了)
+- `slot=` 在 template 标签上的写法(如 `<template slot="header">`)改成 `<template #header>`
+- 文件中可能存在 `Vue.set` / `Vue.delete` —— 模板里不会有,script 里如有出现,改成直接赋值 / `delete obj.key`
+
+## 4. 验证
+
+迁移完成后,对每个文件用以下 grep 自查(应该全部为空):
+
+```bash
+grep -E '\.sync=|\.native|<el-submenu|</el-submenu|slot=|slot-scope|el-icon-|this\.\$message|this\.\$confirm|this\.\$alert|this\.\$prompt|element-ui|vue-quill-editor|Vue\.set|Vue\.delete|size="mini"|size="medium"|\.doClose\(' <file>
+```

+ 40 - 0
pc-v3/scripts/capture-one.js

@@ -0,0 +1,40 @@
+/* eslint-disable */
+// 测试脚本:只截一张图(借记卡权益列表)
+const { chromium } = require('playwright')
+const path = require('path')
+const fs = require('fs')
+
+const BASE = 'http://localhost:8080'
+const OUT_DIR = path.resolve(__dirname, '../../prd/汇丰银行/screenshots')
+
+;(async () => {
+  console.log('启动 chromium...')
+  const browser = await chromium.launch({ headless: true })
+  const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
+  const page = await context.newPage()
+
+  console.log('访问登录页注入 token...')
+  await page.goto(BASE + '/login', { waitUntil: 'domcontentloaded' })
+  await page.evaluate(() => {
+    localStorage.setItem('token', 'mock-token-' + Date.now())
+    localStorage.setItem('username', 'admin')
+  })
+
+  const url = BASE + '/#/customization/hsbc/equity-products'
+  console.log('访问', url)
+  try {
+    await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 })
+  } catch (e) {
+    console.log('networkidle 超时,降级 domcontentloaded')
+    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 })
+  }
+  await page.waitForTimeout(1500)
+
+  if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true })
+  const file = path.join(OUT_DIR, '2-1-equity-products-list.png')
+  await page.screenshot({ path: file, fullPage: true })
+  const size = (fs.statSync(file).size / 1024).toFixed(0)
+  console.log(`✓ ${file}  (${size}KB)`)
+
+  await browser.close()
+})()

+ 120 - 0
pc-v3/scripts/capture-screenshots.js

@@ -0,0 +1,120 @@
+/* eslint-disable */
+// 自动截图脚本:登录后逐页访问、等待加载、截图保存到 prd/汇丰银行/screenshots/
+// 用法:node pc/scripts/capture-screenshots.js
+// 前置:pc/ 下 npm run serve 已在 http://localhost:8080 运行
+
+const { chromium } = require('playwright')
+const path = require('path')
+const fs = require('fs')
+
+const BASE = 'http://localhost:8080'
+const OUT_DIR = path.resolve(__dirname, '../../prd/汇丰银行/screenshots')
+
+// 页面清单:{ name: 输出文件名(不带扩展名), path: 路由, step?: 多步骤页的某一步, wait?: 额外等待 ms, action?: 截图前执行的函数 }
+const PAGES = [
+  // §2 借记卡权益
+  { name: '2-1-equity-products-list', path: '/customization/hsbc/equity-products' },
+  { name: '2-2-equity-add-step1', path: '/customization/hsbc/equity-products/add' },
+  { name: '2-2-equity-add-step2', path: '/customization/hsbc/equity-products/add', step: 2 },
+  { name: '2-2-equity-add-step3', path: '/customization/hsbc/equity-products/add', step: 3 },
+  { name: '2-3-equity-edit', path: '/customization/hsbc/equity-products/edit/Q100006' },
+  { name: '2-4-equity-view', path: '/customization/hsbc/equity-products/view/Q100001' },
+  // §3 信用卡权益
+  { name: '3-credit-card-equity-list', path: '/customization/hsbc/credit-card-equity' },
+  // §6 权益审核
+  { name: '6-audit-list', path: '/customization/hsbc/audit' },
+  { name: '6-audit-detail', path: '/customization/hsbc/audit/AR202605150001' },
+  // §三 人群管理
+  { name: 'crowd-task-list', path: '/customization/hsbc/crowd-tasks' },
+  { name: 'crowd-task-add-step1', path: '/customization/hsbc/crowd-tasks/add' },
+  { name: 'crowd-task-add-step2', path: '/customization/hsbc/crowd-tasks/add', step: 2 },
+  { name: 'crowd-name-list-add-step1', path: '/customization/hsbc/crowd-name-lists/add' },
+  { name: 'crowd-name-list-add-step2', path: '/customization/hsbc/crowd-name-lists/add', step: 2 },
+  { name: 'rule-group-list', path: '/customization/hsbc/rule-groups' },
+  { name: 'rule-group-add-step1', path: '/customization/hsbc/rule-groups/add' },
+  { name: 'rule-group-add-step2', path: '/customization/hsbc/rule-groups/add', step: 2 },
+  // §四 发放计划
+  { name: '4-grant-campaign-list', path: '/customization/hsbc/equity-grant-campaign' },
+  { name: '4-grant-campaign-add', path: '/customization/hsbc/equity-grant-campaign/add' },
+  // §五 页面管理
+  { name: '5-page-home', path: '/customization/hsbc/pages/home' },
+  { name: '5-page-zone', path: '/customization/hsbc/pages/zone' },
+  { name: '5-page-assembly', path: '/customization/hsbc/pages/assembly' },
+  // Channel/Campaign
+  { name: 'channel-management', path: '/customization/hsbc/channel' },
+  { name: 'campaign-management', path: '/customization/hsbc/campaign' },
+  // §六 短信管理
+  { name: '6-sms-template-list', path: '/customization/hsbc/sms-templates' },
+  { name: '6-sms-send-record', path: '/customization/hsbc/sms-send-records' },
+  // §七 报表中心
+  { name: '7-points-detail', path: '/customization/hsbc/points-detail' },
+  { name: '7-customer-equity-grant', path: '/customization/hsbc/equity-grant' },
+  { name: '7-cash-discount-grant', path: '/customization/hsbc/cash-discount-grant' }
+]
+
+async function waitDevServer() {
+  const fetchUrl = BASE + '/'
+  for (let i = 0; i < 60; i++) {
+    try {
+      const res = await fetch(fetchUrl)
+      if (res.ok || res.status === 200) return true
+    } catch (_) { /* not ready */ }
+    await new Promise(r => setTimeout(r, 1000))
+  }
+  throw new Error('dev server 未在 60s 内就绪,请确认 npm run serve 已启动')
+}
+
+async function gotoStep(page, stepIdx) {
+  // 三步骤向导(添加/编辑/规则组/客群任务)通过点击「下一步」翻到目标 step
+  // stepIdx 是 1-based 业务步骤;首屏已是 step 1,则点击 stepIdx-1 次「下一步」
+  for (let i = 1; i < stepIdx; i++) {
+    const nextBtn = page.locator('button:has-text("下一步")').first()
+    if (await nextBtn.count() === 0) break
+    await nextBtn.click().catch(() => {})
+    await page.waitForTimeout(400)
+  }
+}
+
+;(async () => {
+  console.log('[1/4] 等待 dev server 就绪...')
+  await waitDevServer()
+  console.log('     dev server OK')
+
+  console.log('[2/4] 启动 chromium...')
+  const browser = await chromium.launch({ headless: true })
+  // 视口宽高按 1/0.8 放大,让一屏容纳更多内容(等价于"缩放 80%"的视觉效果)
+  const context = await browser.newContext({ viewport: { width: 1800, height: 1125 }, deviceScaleFactor: 2 })
+  const page = await context.newPage()
+
+  console.log('[3/4] 注入 token 跳过登录...')
+  await page.goto(BASE + '/#/login', { waitUntil: 'domcontentloaded' })
+  await page.evaluate(() => {
+    localStorage.setItem('token', 'mock-token-' + Date.now())
+    localStorage.setItem('username', 'admin')
+  })
+
+  console.log(`[4/4] 开始截图(共 ${PAGES.length} 张)...`)
+  const results = []
+  for (const item of PAGES) {
+    const url = BASE + '/#' + item.path
+    try {
+      await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 })
+    } catch (_) {
+      // networkidle 在 dev server 热更新场景下可能 timeout,降级用 domcontentloaded
+      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 })
+    }
+    await page.waitForTimeout(800)
+    if (item.step && item.step > 1) {
+      await gotoStep(page, item.step)
+      await page.waitForTimeout(400)
+    }
+    const file = path.join(OUT_DIR, item.name + '.png')
+    await page.screenshot({ path: file, fullPage: true })
+    const size = (fs.statSync(file).size / 1024).toFixed(0)
+    console.log(`     ✓ ${item.name}.png  (${size}KB)`)
+    results.push({ name: item.name, file })
+  }
+
+  await browser.close()
+  console.log(`\n完成。共 ${results.length} 张,输出目录:${OUT_DIR}`)
+})()

+ 171 - 0
pc-v3/scripts/capture-supplements.js

@@ -0,0 +1,171 @@
+/* eslint-disable */
+// B 类补截:每张图独立 try-catch
+const { chromium } = require('playwright')
+const path = require('path')
+
+const BASE = 'http://localhost:8080'
+const OUT_DIR = path.resolve(__dirname, '../../prd/汇丰银行/screenshots')
+
+async function shot(page, name, full = false) {
+  await page.screenshot({ path: path.join(OUT_DIR, name + '.png'), fullPage: full })
+  console.log('✓', name)
+}
+
+async function safe(label, fn) {
+  try { await fn() } catch (e) { console.log('✗', label, '—', e.message.split('\n')[0]) }
+}
+
+;(async () => {
+  const browser = await chromium.launch({ headless: true })
+  const context = await browser.newContext({ viewport: { width: 1800, height: 1125 }, deviceScaleFactor: 2 })
+  const page = await context.newPage()
+
+  await page.goto(BASE + '/#/login', { waitUntil: 'domcontentloaded' })
+  await page.evaluate(() => {
+    localStorage.setItem('token', 'mock-token-' + Date.now())
+    localStorage.setItem('username', 'admin')
+  })
+
+  // ---- 详情页(3 张)----
+  for (const item of [
+    { name: 'crowd-task-view', url: '/#/customization/hsbc/crowd-tasks/view/00000120251224' },
+    { name: 'rule-group-view', url: '/#/customization/hsbc/rule-groups/view/RG20260401001' },
+    { name: '4-grant-campaign-view', url: '/#/customization/hsbc/equity-grant-campaign/view/P000001' }
+  ]) {
+    await safe(item.name, async () => {
+      await page.goto(BASE + item.url, { waitUntil: 'domcontentloaded' })
+      await page.waitForTimeout(1200)
+      await shot(page, item.name, true)
+    })
+  }
+
+  // ---- 添加权益 4 个分支 ----
+  const goAdd = async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products/add', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(800)
+  }
+
+  await safe('2-2-equity-add-source-rongshu', async () => {
+    await goAdd()
+    await page.locator('label.el-radio:has-text("荣数商品")').first().click()
+    await page.waitForTimeout(400)
+    await shot(page, '2-2-equity-add-source-rongshu', true)
+  })
+
+  await safe('2-2-equity-add-third-list', async () => {
+    await goAdd()
+    await page.locator('label.el-radio:has-text("第三方")').first().click()
+    await page.waitForTimeout(400)
+    await shot(page, '2-2-equity-add-third-list', true)
+  })
+
+  await safe('2-2-equity-add-third-detail', async () => {
+    await goAdd()
+    await page.locator('label.el-radio:has-text("第三方")').first().click()
+    await page.waitForTimeout(300)
+    await page.locator('label.el-radio-button:has-text("有")').first().click()
+    await page.waitForTimeout(400)
+    await shot(page, '2-2-equity-add-third-detail', true)
+  })
+
+  await safe('2-2-equity-add-third-miniprogram', async () => {
+    await goAdd()
+    await page.locator('label.el-radio:has-text("第三方")').first().click()
+    await page.waitForTimeout(300)
+    await page.locator('.el-form-item:has-text("外部权益链接") .el-select').click()
+    await page.waitForTimeout(300)
+    await page.locator('.el-select-dropdown__item:has-text("小程序链接")').click()
+    await page.waitForTimeout(400)
+    await shot(page, '2-2-equity-add-third-miniprogram', true)
+  })
+
+  // ---- 关键弹窗 ----
+  // 下架确认(权益A 已上架 + 审核通过)
+  await safe('dialog-offshelf-confirm', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    // 通过权益A 这一行的"下架"按钮
+    await page.locator('tr:has-text("权益A") button:has-text("下架")').first().click({ force: true })
+    await page.waitForTimeout(500)
+    await shot(page, 'dialog-offshelf-confirm', false)
+    await page.locator('.el-message-box__btns button:has-text("取消")').click({ force: true }).catch(() => {})
+    await page.waitForTimeout(300)
+  })
+
+  // 删除确认(权益F 草稿状态)
+  await safe('dialog-delete-confirm', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    await page.locator('tr:has-text("权益F(草稿)") button.el-button--text:has(i.el-icon-more)').first().click({ force: true })
+    await page.waitForTimeout(500)
+    await page.evaluate(() => {
+      const el = [...document.querySelectorAll('.more-actions-item')]
+        .find(e => e.textContent.trim() === '删除' && e.offsetParent !== null)
+      el && el.click()
+    })
+    await page.waitForTimeout(500)
+    await shot(page, 'dialog-delete-confirm', false)
+    await page.locator('.el-message-box__btns button:has-text("取消")').click({ force: true }).catch(() => {})
+    await page.waitForTimeout(300)
+  })
+
+  // 调整顺序气泡(权益A 行)
+  await safe('popover-adjust-order', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    await page.locator('tr:has-text("权益A") button.el-button--text:has(i.el-icon-more)').first().click({ force: true })
+    await page.waitForTimeout(500)
+    await page.evaluate(() => {
+      const el = [...document.querySelectorAll('.more-actions-item')]
+        .find(e => e.textContent.trim() === '调整显示顺序' && e.offsetParent !== null)
+      el && el.click()
+    })
+    await page.waitForTimeout(500)
+    await shot(page, 'popover-adjust-order', false)
+    // 关闭:点空白处
+    await page.mouse.click(10, 10).catch(() => {})
+    await page.waitForTimeout(300)
+  })
+
+  // 设置显示页面对话框(信用卡列表)
+  await safe('dialog-show-pages', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/credit-card-equity', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    await page.locator('button:has-text("设置显示页面")').first().click({ force: true })
+    await page.waitForTimeout(700)
+    await shot(page, 'dialog-show-pages', false)
+    await page.locator('.el-dialog__footer button:has-text("取消")').click({ force: true }).catch(() => {})
+    await page.waitForTimeout(300)
+  })
+
+  // 生成链接弹窗
+  await safe('dialog-equity-link', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    await page.locator('tr:has-text("权益A") button.el-button--text:has(i.el-icon-more)').first().click({ force: true })
+    await page.waitForTimeout(500)
+    await page.evaluate(() => {
+      const el = [...document.querySelectorAll('.more-actions-item')]
+        .find(e => e.textContent.trim() === '生成链接' && e.offsetParent !== null)
+      el && el.click()
+    })
+    await page.waitForTimeout(700)
+    await shot(page, 'dialog-equity-link', false)
+    await page.locator('.el-dialog__headerbtn').click({ force: true }).catch(() => {})
+    await page.waitForTimeout(300)
+  })
+
+  // 荣数商品选择弹窗
+  await safe('dialog-rongshu-product', async () => {
+    await page.goto(BASE + '/#/customization/hsbc/equity-products/add', { waitUntil: 'domcontentloaded' })
+    await page.waitForTimeout(1000)
+    await page.locator('label.el-radio:has-text("荣数商品")').first().click()
+    await page.waitForTimeout(500)
+    await page.locator('button:has-text("选择")').first().click({ force: true })
+    await page.waitForTimeout(800)
+    await shot(page, 'dialog-rongshu-product', false)
+  })
+
+  await browser.close()
+  console.log('\nB 类截图完成。')
+})()

+ 32 - 0
pc-v3/scripts/dom-probe.mjs

@@ -0,0 +1,32 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/equity-products/add')
+await p.waitForTimeout(2500)
+// inspect first quill-editor
+const html = await p.evaluate(() => {
+  const el = document.querySelector('.ql-toolbar')
+  if (!el) return 'no toolbar'
+  const root = el.parentElement
+  return {
+    rootTag: root.tagName,
+    rootClass: root.className,
+    rootStyle: getComputedStyle(root).cssText.slice(0, 400),
+    childCount: root.children.length,
+    children: Array.from(root.children).map(c => ({
+      tag: c.tagName,
+      cls: c.className,
+      display: getComputedStyle(c).display,
+      width: getComputedStyle(c).width,
+      height: getComputedStyle(c).height
+    }))
+  }
+})
+console.log(JSON.stringify(html, null, 2))
+await b.close()

+ 28 - 0
pc-v3/scripts/dom-probe2.mjs

@@ -0,0 +1,28 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/equity-products/add')
+await p.waitForTimeout(2000)
+const r = await p.evaluate(() => {
+  const tb = document.querySelector('.ql-toolbar')
+  const root = tb?.parentElement // .quill-editor wrapper
+  const formItem = root?.closest('.el-form-item')
+  const formItemContent = root?.closest('.el-form-item__content')
+  const editor = document.querySelector('.ql-editor')
+  return {
+    root: root ? { cls: root.className, display: getComputedStyle(root).display, w: getComputedStyle(root).width, h: getComputedStyle(root).height, position: getComputedStyle(root).position } : null,
+    formItem: formItem ? { h: getComputedStyle(formItem).height, mb: getComputedStyle(formItem).marginBottom } : null,
+    formItemContent: formItemContent ? { h: getComputedStyle(formItemContent).height, display: getComputedStyle(formItemContent).display } : null,
+    toolbar: tb ? { h: getComputedStyle(tb).height } : null,
+    container: document.querySelector('.ql-container') ? { h: getComputedStyle(document.querySelector('.ql-container')).height, position: getComputedStyle(document.querySelector('.ql-container')).position } : null,
+    editor: editor ? { h: getComputedStyle(editor).height, minH: getComputedStyle(editor).minHeight } : null
+  }
+})
+console.log(JSON.stringify(r, null, 2))
+await b.close()

+ 25 - 0
pc-v3/scripts/probe-channel.mjs

@@ -0,0 +1,25 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/channel')
+await p.waitForTimeout(1500)
+await p.click('button:has-text("新建 Channel")')
+await p.waitForTimeout(800)
+const r = await p.evaluate(() => {
+  const sel = document.querySelector('.el-dialog .inline-field .el-select')
+  const wrap = sel?.querySelector('.el-select__wrapper')
+  const parent = sel?.parentElement
+  return {
+    inlineField: parent ? { display: getComputedStyle(parent).display, w: getComputedStyle(parent).width } : null,
+    selectRoot: sel ? { display: getComputedStyle(sel).display, w: getComputedStyle(sel).width, flex: getComputedStyle(sel).flex, minW: getComputedStyle(sel).minWidth } : null,
+    selectWrapper: wrap ? { display: getComputedStyle(wrap).display, w: getComputedStyle(wrap).width } : null
+  }
+})
+console.log(JSON.stringify(r, null, 2))
+await b.close()

BIN
pc-v3/scripts/screenshot-equity-products.png


+ 33 - 0
pc-v3/scripts/screenshot.mjs

@@ -0,0 +1,33 @@
+import { chromium } from 'playwright'
+const browser = await chromium.launch()
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
+const page = await ctx.newPage()
+const errors = []
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message))
+page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()) })
+await page.goto('http://localhost:8080/')
+await page.waitForTimeout(500)
+await page.fill('input[placeholder="请输入账号"]', 'admin')
+await page.fill('input[placeholder="请输入密码"]', '123456')
+await page.click('button.login-btn')
+await page.waitForTimeout(1500)
+await page.screenshot({ path: 'scripts/shot-equity.png' })
+
+const pages = [
+  ['#/customization/hsbc/crowd-tasks', 'crowd-tasks'],
+  ['#/customization/hsbc/tags', 'tags'],
+  ['#/customization/hsbc/rule-groups', 'rule-groups'],
+  ['#/customization/hsbc/equity-grant-campaign', 'grant-campaign'],
+  ['#/customization/hsbc/pages/home', 'page-home'],
+  ['#/customization/hsbc/equity-products/add', 'add-equity'],
+  ['#/customization/hsbc/sms-templates', 'sms-templates'],
+]
+for (const [hash, name] of pages) {
+  await page.goto('http://localhost:8080/' + hash)
+  await page.waitForTimeout(1500)
+  await page.screenshot({ path: `scripts/shot-${name}.png` })
+}
+
+console.log('ERRORS:', errors.length)
+errors.forEach(e => console.log('  -', e))
+await browser.close()

BIN
pc-v3/scripts/shot-add-equity.png


BIN
pc-v3/scripts/shot-channel-dialog.png


+ 17 - 0
pc-v3/scripts/shot-channel.mjs

@@ -0,0 +1,17 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/channel')
+await p.waitForTimeout(1500)
+await p.click('button:has-text("新建 Channel")')
+await p.waitForTimeout(800)
+const w = await p.evaluate(() => getComputedStyle(document.querySelector('.el-dialog .inline-field .el-select')).width)
+console.log('SELECT_WIDTH:', w)
+await p.screenshot({ path: 'scripts/shot-channel-dialog.png' })
+await b.close()

BIN
pc-v3/scripts/shot-crowd-tasks.png


BIN
pc-v3/scripts/shot-equity.png


BIN
pc-v3/scripts/shot-grant-campaign.png


BIN
pc-v3/scripts/shot-page-home.png


BIN
pc-v3/scripts/shot-quill-final.png


BIN
pc-v3/scripts/shot-quill-fixed.png


BIN
pc-v3/scripts/shot-quill-fixed2.png


+ 21 - 0
pc-v3/scripts/shot-quill.mjs

@@ -0,0 +1,21 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1600, height: 1000 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/equity-products/add')
+await p.waitForTimeout(2000)
+const dims = await p.evaluate(() => {
+  const cs = document.querySelectorAll('.ql-container')
+  return Array.from(cs).map(c => ({ w: getComputedStyle(c).width, h: getComputedStyle(c).height }))
+})
+console.log('CONTAINERS:', JSON.stringify(dims))
+// scroll to find the editor
+await p.evaluate(() => { document.querySelector('.ql-toolbar')?.scrollIntoView({ block: 'center' }) })
+await p.waitForTimeout(500)
+await p.screenshot({ path: 'scripts/shot-quill-fixed.png' })
+await b.close()

+ 26 - 0
pc-v3/scripts/shot-quill2.mjs

@@ -0,0 +1,26 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/equity-products/add')
+await p.waitForTimeout(2000)
+const r = await p.evaluate(() => {
+  const fi = document.querySelector('.ql-toolbar')?.closest('.el-form-item')
+  return {
+    formItemHeight: fi ? getComputedStyle(fi).height : null,
+    containerHeight: getComputedStyle(document.querySelector('.ql-container')).height,
+    toolbarHeight: getComputedStyle(document.querySelector('.ql-toolbar')).height
+  }
+})
+console.log(JSON.stringify(r))
+const tb = await p.$('.ql-toolbar')
+const box = await tb.boundingBox()
+await p.evaluate((y) => window.scrollTo(0, y - 200), box.y)
+await p.waitForTimeout(500)
+await p.screenshot({ path: 'scripts/shot-quill-fixed2.png' })
+await b.close()

+ 19 - 0
pc-v3/scripts/shot-quill3.mjs

@@ -0,0 +1,19 @@
+import { chromium } from 'playwright'
+const b = await chromium.launch()
+const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } })
+const p = await ctx.newPage()
+await p.goto('http://localhost:8080/')
+await p.fill('input[placeholder="请输入账号"]', 'admin')
+await p.fill('input[placeholder="请输入密码"]', '123456')
+await p.click('button.login-btn')
+await p.waitForTimeout(1500)
+await p.goto('http://localhost:8080/#/customization/hsbc/equity-products/add')
+await p.waitForTimeout(2000)
+// scroll quill into view inside content scroller
+await p.evaluate(() => {
+  const tb = document.querySelector('.ql-toolbar')
+  tb?.scrollIntoView({ block: 'center' })
+})
+await p.waitForTimeout(500)
+await p.screenshot({ path: 'scripts/shot-quill-final.png' })
+await b.close()

BIN
pc-v3/scripts/shot-rule-groups.png


BIN
pc-v3/scripts/shot-sms-templates.png


BIN
pc-v3/scripts/shot-tags.png


+ 32 - 0
pc-v3/scripts/smoke.mjs

@@ -0,0 +1,32 @@
+import { chromium } from 'playwright'
+const errors = []
+const browser = await chromium.launch()
+const ctx = await browser.newContext()
+const page = await ctx.newPage()
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message))
+page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()) })
+await page.goto('http://localhost:8080/', { waitUntil: 'networkidle', timeout: 15000 })
+await page.waitForTimeout(1000)
+const title = await page.title()
+const url = page.url()
+const loginVisible = await page.locator('.login-box').count()
+console.log('TITLE:', title)
+console.log('URL:', url)
+console.log('LOGIN_BOX_COUNT:', loginVisible)
+console.log('ERRORS:', errors.length)
+errors.forEach(e => console.log('  -', e))
+// Try login
+if (loginVisible > 0) {
+  await page.fill('input[placeholder="请输入账号"]', 'admin')
+  await page.fill('input[placeholder="请输入密码"]', '123456')
+  await page.click('button.login-btn')
+  await page.waitForTimeout(2000)
+  console.log('AFTER_LOGIN_URL:', page.url())
+  const sidebar = await page.locator('.sidebar').count()
+  console.log('SIDEBAR_COUNT:', sidebar)
+  const contentText = await page.locator('.content').innerText().catch(() => '')
+  console.log('CONTENT_PREVIEW:', contentText.slice(0, 200))
+}
+console.log('FINAL_ERRORS:', errors.length)
+errors.forEach(e => console.log('  -', e))
+await browser.close()

+ 14 - 0
pc-v3/src/App.vue

@@ -0,0 +1,14 @@
+<template>
+  <router-view />
+</template>
+
+<script>
+export default {
+  name: 'App'
+}
+</script>
+
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; }
+</style>

BIN
pc-v3/src/assets/logo.png


+ 142 - 0
pc-v3/src/components/EquityLinkDialog.vue

@@ -0,0 +1,142 @@
+<template>
+  <el-dialog
+    :title="title"
+    v-model="dialogVisible"
+    width="680px"
+    @open="handleOpen"
+  >
+    <el-form label-width="100px" inline>
+      <el-form-item label="Campaign ID">
+        <el-select v-model="form.campaignId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+          <el-option
+            v-for="opt in campaignOptions"
+            :key="opt.value"
+            :label="opt.label"
+            :value="opt.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="Channel ID">
+        <el-select v-model="form.channelId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+          <el-option
+            v-for="opt in channelOptions"
+            :key="opt.value"
+            :label="opt.label"
+            :value="opt.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <div class="link-section">
+      <div class="link-label"><span class="dot"></span> 权益链接</div>
+      <div class="link-row">
+        <span class="link-type">H5链接</span>
+        <el-input :value="h5Url" readonly size="small" />
+        <el-button size="small" type="primary" plain @click="copyLink(h5Url)">复制</el-button>
+      </div>
+      <div class="link-row">
+        <span class="link-type">小程序链接</span>
+        <el-input :value="appUrl" readonly size="small" />
+        <el-button size="small" type="primary" plain @click="copyLink(appUrl)">复制</el-button>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import { fetchLinkOptions } from '@/utils/linkOptions'
+
+export default {
+  name: 'EquityLinkDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    id: { type: [String, Number], default: '' },
+    pathName: { type: String, default: 'equity' },
+    title: { type: String, default: '生成链接' }
+  },
+  emits: ['update:modelValue'],
+  data() {
+    return {
+      form: { campaignId: '', channelId: '' },
+      campaignOptions: [],
+      channelOptions: []
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    queryStr() {
+      const params = []
+      if (this.form.campaignId) params.push(`CampaignId=${this.form.campaignId}`)
+      if (this.form.channelId) params.push(`Channelid=${this.form.channelId}`)
+      return params.length ? '&' + params.join('&') : ''
+    },
+    h5Url() {
+      return `https://hsbc.com/${this.pathName}?id=${this.id}${this.queryStr}`
+    },
+    appUrl() {
+      return `hsbc-app://${this.pathName}?id=${this.id}${this.queryStr}`
+    }
+  },
+  methods: {
+    async handleOpen() {
+      this.resetForm()
+      const { campaignOptions, channelOptions } = await fetchLinkOptions()
+      this.campaignOptions = campaignOptions
+      this.channelOptions = channelOptions
+    },
+    resetForm() {
+      this.form = { campaignId: '', channelId: '' }
+    },
+    copyLink(text) {
+      if (!text) return
+      navigator.clipboard.writeText(text).then(() => {
+        ElMessage.success('链接已复制到剪贴板')
+      }).catch(() => {
+        ElMessage.error('复制失败,请手动选取')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.link-section {
+  margin-top: 8px;
+  padding: 12px;
+  background: #fafafa;
+  border-radius: 6px;
+  border: 1px solid #ebeef5;
+}
+.link-label {
+  font-weight: 600;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  color: #303133;
+}
+.link-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+.link-row:last-child { margin-bottom: 0; }
+.link-type {
+  font-size: 12px;
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+.dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block;
+  background: #67C23A;
+}
+</style>

+ 58 - 0
pc-v3/src/components/HelloWorld.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="hello">
+    <h1>{{ msg }}</h1>
+    <p>
+      For a guide and recipes on how to configure / customize this project,<br>
+      check out the
+      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
+    </p>
+    <h3>Installed CLI Plugins</h3>
+    <ul>
+      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
+      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
+    </ul>
+    <h3>Essential Links</h3>
+    <ul>
+      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
+      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
+      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
+      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
+      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
+    </ul>
+    <h3>Ecosystem</h3>
+    <ul>
+      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
+      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
+      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
+      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
+      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
+    </ul>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'HelloWorld',
+  props: {
+    msg: String
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+h3 {
+  margin: 40px 0 0;
+}
+ul {
+  list-style-type: none;
+  padding: 0;
+}
+li {
+  display: inline-block;
+  margin: 0 10px;
+}
+a {
+  color: #42b983;
+}
+</style>

+ 252 - 0
pc-v3/src/components/Layout.vue

@@ -0,0 +1,252 @@
+<template>
+  <div class="layout">
+    <!-- 侧边栏 -->
+    <aside :class="['sidebar', { collapsed }]">
+      <div class="logo">
+        <el-icon><Monitor /></el-icon>
+        <span v-if="!collapsed">管理后台</span>
+      </div>
+      <el-menu
+        :default-active="$route.path"
+        router
+        :collapse="collapsed"
+        background-color="#304156"
+        text-color="#bfcbd9"
+        active-text-color="#409EFF"
+      >
+        <el-sub-menu index="customization">
+          <template #title>
+            <el-icon><Setting /></el-icon>
+            <span>定制功能</span>
+          </template>
+          <el-sub-menu index="customization-hsbc">
+            <template #title>汇丰银行</template>
+            <el-sub-menu index="customization-hsbc-pages">
+              <template #title>页面管理</template>
+              <el-menu-item index="/customization/hsbc/pages/home">
+                权益介绍页
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/pages/zone">
+                权益首页
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/pages/assembly">
+                组装页
+              </el-menu-item>
+            </el-sub-menu>
+            <el-sub-menu index="customization-hsbc-equity">
+              <template #title>权益管理</template>
+              <el-menu-item index="/customization/hsbc/equity-products">
+                借记卡权益
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/credit-card-equity">
+                信用卡权益
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/audit">
+                权益审核
+              </el-menu-item>
+            </el-sub-menu>
+            <el-menu-item index="/customization/hsbc/equity-grant-campaign">
+              发放计划
+            </el-menu-item>
+            <el-sub-menu index="customization-hsbc-crowd">
+              <template #title>人群管理</template>
+              <el-menu-item index="/customization/hsbc/crowd-tasks">
+                客群任务管理
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/rule-groups">
+                规则组管理
+              </el-menu-item>
+            </el-sub-menu>
+            <el-sub-menu index="customization-hsbc-sms">
+              <template #title>短信管理</template>
+              <el-menu-item index="/customization/hsbc/sms-templates">
+                短信模板管理
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/sms-send-records">
+                发送记录查询
+              </el-menu-item>
+            </el-sub-menu>
+            <el-sub-menu index="customization-hsbc-channel">
+              <template #title>渠道管理</template>
+              <el-menu-item index="/customization/hsbc/channel">
+                Channel管理
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/campaign">
+                Campaign管理
+              </el-menu-item>
+            </el-sub-menu>
+            <el-sub-menu index="customization-hsbc-report">
+              <template #title>报表中心</template>
+              <el-menu-item index="/customization/hsbc/points-detail">
+                积分明细查询
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/equity-grant">
+                客户权益发放
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/cash-discount-grant">
+                立减金发放明细
+              </el-menu-item>
+            </el-sub-menu>
+          </el-sub-menu>
+        </el-sub-menu>
+        <el-sub-menu index="system">
+          <template #title>
+            <el-icon><Tools /></el-icon>
+            <span>系统设置</span>
+          </template>
+          <el-menu-item index="/system/users">用户管理</el-menu-item>
+          <el-menu-item index="/system/roles">角色管理</el-menu-item>
+        </el-sub-menu>
+      </el-menu>
+    </aside>
+
+    <!-- 主内容区 -->
+    <div class="main">
+      <!-- 顶部栏 -->
+      <header class="header">
+        <el-icon class="toggle-btn" @click="collapsed = !collapsed">
+          <component :is="collapsed ? 'Expand' : 'Fold'" />
+        </el-icon>
+        <el-breadcrumb separator="/" class="breadcrumb">
+          <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
+          <el-breadcrumb-item v-if="currentTitle">
+            {{ currentTitle }}
+          </el-breadcrumb-item>
+        </el-breadcrumb>
+        <el-dropdown trigger="click" @command="handleUserCommand">
+          <span class="user-info">
+            <el-icon><UserFilled /></el-icon>
+            {{ username }}
+            <el-icon><ArrowDown /></el-icon>
+          </span>
+          <template #dropdown>
+            <el-dropdown-menu>
+              <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+            </el-dropdown-menu>
+          </template>
+        </el-dropdown>
+      </header>
+
+      <!-- 页面内容 -->
+      <div class="content">
+        <router-view />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'Layout',
+  data() {
+    return {
+      collapsed: false,
+      username: localStorage.getItem('username') || '用户'
+    }
+  },
+  computed: {
+    currentTitle() {
+      return this.$route.meta?.title || ''
+    }
+  },
+  methods: {
+    handleUserCommand(cmd) {
+      if (cmd === 'logout') {
+        ElMessageBox.confirm('确定要退出登录吗?', '提示', {
+          confirmButtonText: '退出',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          localStorage.removeItem('token')
+          localStorage.removeItem('username')
+          this.$router.replace('/login')
+        }).catch(() => {})
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.layout { display: flex; height: 100vh; overflow: hidden; }
+
+.sidebar {
+  width: 210px;
+  background: #304156;
+  transition: width 0.3s;
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+  overflow: hidden;
+}
+.sidebar.collapsed { width: 64px; }
+
+.logo {
+  height: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  color: #fff;
+  font-size: 18px;
+  font-weight: bold;
+  background: #263445;
+  overflow: hidden;
+  white-space: nowrap;
+}
+.logo .el-icon { font-size: 22px; flex-shrink: 0; }
+
+.el-menu {
+  border: none;
+  flex: 1;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+.el-menu--collapse { width: 64px; }
+
+.el-menu::-webkit-scrollbar { width: 6px; }
+.el-menu::-webkit-scrollbar-thumb {
+  background: rgba(191, 203, 217, .28);
+  border-radius: 6px;
+}
+.el-menu::-webkit-scrollbar-track { background: transparent; }
+
+.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
+
+.header {
+  height: 60px;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0,21,41,.08);
+  display: flex;
+  align-items: center;
+  padding: 0 20px;
+  gap: 16px;
+  flex-shrink: 0;
+}
+
+.toggle-btn { font-size: 20px; cursor: pointer; color: #606266; }
+.toggle-btn:hover { color: #409EFF; }
+
+.breadcrumb { flex: 1; }
+
+.user-info {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  color: #606266;
+  cursor: pointer;
+  font-size: 14px;
+}
+.user-info:hover { color: #409EFF; }
+.user-info .el-icon { font-size: 16px; }
+
+.content {
+  flex: 1;
+  padding: 20px;
+  background: #f0f2f5;
+  overflow-y: auto;
+}
+</style>

+ 120 - 0
pc-v3/src/components/RongshuProductDialog.vue

@@ -0,0 +1,120 @@
+<template>
+  <el-dialog
+    title="选择荣数商品"
+    v-model="dialogVisible"
+    width="900px"
+    @open="onOpen"
+  >
+    <el-form :model="filter" inline>
+      <el-form-item label="商品名称">
+        <el-input
+          v-model="filter.name"
+          placeholder="请输入"
+          clearable
+          style="width: 220px"
+          @keyup.enter="handleSearch"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleSearch">查询</el-button>
+        <el-button @click="handleReset">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="pagedData" border stripe size="small" style="width: 100%">
+      <el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
+      <el-table-column prop="merchant" label="商户名称" width="160" show-overflow-tooltip />
+      <el-table-column label="银行结算价" width="120" align="right">
+        <template #default="{ row }">¥{{ row.settlePrice.toFixed(2) }}</template>
+      </el-table-column>
+      <el-table-column prop="stock" label="库存数" width="100" align="right" />
+      <el-table-column label="操作" width="80" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="text" size="small" @click="handleSelect(row)">选择</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="pagination-bar">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :page-size="10"
+        v-model:current-page="currentPage"
+        :total="filteredData.length"
+      />
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+const MOCK_PRODUCTS = [
+  { name: '微信立减金 5元(满10减5)', merchant: '微信支付', settlePrice: 5, stock: 2000, mainImage: [{ name: 'wx-5.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A55' }] },
+  { name: '微信立减金 8元(满30减8)', merchant: '微信支付', settlePrice: 8, stock: 1500, mainImage: [{ name: 'wx-8.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A58' }] },
+  { name: '微信立减金 10元(满50减10)', merchant: '微信支付', settlePrice: 10, stock: 1200, mainImage: [{ name: 'wx-10.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A510' }] },
+  { name: '微信立减金 18元(满88减18)', merchant: '微信支付', settlePrice: 18, stock: 1000, mainImage: [{ name: 'wx-18.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A518' }] },
+  { name: '微信立减金 20元(满100减20)', merchant: '微信支付', settlePrice: 20, stock: 800, mainImage: [{ name: 'wx-20.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A520' }] },
+  { name: '微信立减金 30元(满150减30)', merchant: '微信支付', settlePrice: 30, stock: 600, mainImage: [{ name: 'wx-30.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A530' }] },
+  { name: '微信立减金 50元(满200减50)', merchant: '微信支付', settlePrice: 50, stock: 400, mainImage: [{ name: 'wx-50.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A550' }] },
+  { name: '微信立减金 66元(满300减66)', merchant: '微信支付', settlePrice: 66, stock: 300, mainImage: [{ name: 'wx-66.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A566' }] },
+  { name: '微信立减金 88元(满388减88)', merchant: '微信支付', settlePrice: 88, stock: 200, mainImage: [{ name: 'wx-88.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A588' }] },
+  { name: '微信立减金 100元(满500减100)', merchant: '微信支付', settlePrice: 100, stock: 150, mainImage: [{ name: 'wx-100.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A5100' }] },
+  { name: '微信立减金 128元(满600减128)', merchant: '微信支付', settlePrice: 128, stock: 100, mainImage: [{ name: 'wx-128.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A5128' }] },
+  { name: '微信立减金 200元(满800减200)', merchant: '微信支付', settlePrice: 200, stock: 80, mainImage: [{ name: 'wx-200.png', url: 'https://placehold.co/200x200/07c160/ffffff?text=%C2%A5200' }] }
+]
+
+export default {
+  name: 'RongshuProductDialog',
+  props: {
+    modelValue: { type: Boolean, default: false }
+  },
+  emits: ['update:modelValue', 'select'],
+  data() {
+    return {
+      filter: { name: '' },
+      currentPage: 1,
+      products: MOCK_PRODUCTS
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    filteredData() {
+      if (!this.filter.name) return this.products
+      const kw = this.filter.name.trim()
+      return this.products.filter(p => p.name.includes(kw))
+    },
+    pagedData() {
+      const start = (this.currentPage - 1) * 10
+      return this.filteredData.slice(start, start + 10)
+    }
+  },
+  methods: {
+    onOpen() {
+      this.filter = { name: '' }
+      this.currentPage = 1
+    },
+    handleSearch() {
+      this.currentPage = 1
+    },
+    handleReset() {
+      this.filter = { name: '' }
+      this.currentPage = 1
+    },
+    handleSelect(row) {
+      this.$emit('select', { ...row })
+      this.dialogVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pagination-bar {
+  margin-top: 12px;
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

+ 91 - 0
pc-v3/src/components/ShowPagesDialog.vue

@@ -0,0 +1,91 @@
+<template>
+  <el-dialog
+    title="设置显示页面"
+    v-model="dialogVisible"
+    width="560px"
+    @open="onOpen"
+  >
+    <el-form :model="form" label-width="140px" label-position="left">
+      <el-form-item label="权益首页/列表页">
+        <el-radio-group v-model="form.showOnHome">
+          <el-radio label="显示">显示</el-radio>
+          <el-radio label="不显示">不显示</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="组装页">
+        <el-radio-group v-model="form.showOnAssembly">
+          <el-radio label="显示">显示</el-radio>
+          <el-radio label="不显示">不显示</el-radio>
+        </el-radio-group>
+        <div
+          v-if="form.showOnAssembly === '显示'"
+          style="background: #f1f2f5;padding: 16px;margin-top: 10px;"
+        >
+          <el-select
+            v-model="form.assemblyPages"
+            multiple
+            filterable
+            placeholder="请选择组装页"
+            style="width: 100%"
+          >
+            <el-option label="组装页A" value="组装页A"></el-option>
+            <el-option label="组装页B" value="组装页B"></el-option>
+            <el-option label="组装页C" value="组装页C"></el-option>
+            <el-option label="组装页D" value="组装页D"></el-option>
+          </el-select>
+        </div>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确定</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'ShowPagesDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    row: { type: Object, default: null }
+  },
+  emits: ['update:modelValue', 'confirm'],
+  data() {
+    return {
+      form: {
+        showOnHome: '显示',
+        showOnAssembly: '不显示',
+        assemblyPages: []
+      }
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() { return this.modelValue },
+      set(val) { this.$emit('update:modelValue', val) }
+    }
+  },
+  methods: {
+    onOpen() {
+      if (this.row) {
+        this.form.showOnHome = this.row.showOnHome || '显示'
+        this.form.showOnAssembly = this.row.showOnAssembly || '不显示'
+        this.form.assemblyPages = Array.isArray(this.row.assemblyPages)
+          ? [...this.row.assemblyPages]
+          : []
+      } else {
+        this.form.showOnHome = '显示'
+        this.form.showOnAssembly = '不显示'
+        this.form.assemblyPages = []
+      }
+    },
+    handleConfirm() {
+      this.$emit('confirm', { ...this.form, assemblyPages: [...this.form.assemblyPages] })
+      this.dialogVisible = false
+    }
+  }
+}
+</script>

+ 123 - 0
pc-v3/src/components/crowdNameList/AdjustMemberDialog.vue

@@ -0,0 +1,123 @@
+<template>
+  <el-dialog
+    title="人员调整"
+    v-model="innerVisible"
+    width="520px"
+    :close-on-click-modal="false"
+    @close="reset"
+  >
+    <el-form label-width="100px" label-position="right">
+      <el-form-item label="客群编号" required>
+        <el-select
+          v-model="form.listId"
+          placeholder="请选择"
+          filterable
+          style="width: 100%"
+        >
+          <el-option
+            v-for="item in availableLists"
+            :key="item.id"
+            :label="`${item.id}-${item.name}`"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="客户号" required>
+        <el-input
+          v-model.trim="form.customerId"
+          placeholder="请输入客户号(5 位数字)"
+          maxlength="10"
+        />
+      </el-form-item>
+
+      <el-form-item label="调整方向" required>
+        <el-select v-model="form.direction" placeholder="请选择" style="width: 100%">
+          <el-option label="调增 - A" value="A" />
+          <el-option label="调减 - D" value="D" />
+        </el-select>
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel">取 消</el-button>
+        <el-button type="primary" @click="handleSave">保 存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import crowdNameListStore from '@/store/crowdNameListStore'
+
+export default {
+  name: 'AdjustMemberDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    defaultListId: { type: String, default: '' }
+  },
+  emits: ['update:modelValue', 'saved'],
+  data() {
+    return {
+      form: { listId: '', customerId: '', direction: '' }
+    }
+  },
+  computed: {
+    innerVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    availableLists() {
+      return crowdNameListStore.list.filter((l) => l.status !== 'stopped')
+    }
+  },
+  watch: {
+    modelValue(v) {
+      if (v) {
+        this.reset()
+        if (this.defaultListId) this.form.listId = this.defaultListId
+      }
+    }
+  },
+  methods: {
+    reset() {
+      this.form = { listId: '', customerId: '', direction: '' }
+    },
+    handleSave() {
+      if (!this.form.listId) {
+        ElMessage.error('请选择客群编号')
+        return
+      }
+      if (!this.form.customerId) {
+        ElMessage.error('请输入客户号')
+        return
+      }
+      if (!this.form.direction) {
+        ElMessage.error('请选择调整方向')
+        return
+      }
+      const r = crowdNameListStore.adjustOne({
+        listId: this.form.listId,
+        customerId: this.form.customerId,
+        direction: this.form.direction
+      })
+      if (r.ok) {
+        ElMessage.success(`调整提交成功(${r.action === 'add' ? '已新增' : '已移除'}),审核通过后生效(demo 默认通过)`)
+        this.innerVisible = false
+        this.$emit('saved')
+      } else {
+        ElMessage.error(r.reason)
+      }
+    },
+    handleCancel() {
+      this.innerVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dialog-footer { text-align: center; }
+</style>

+ 205 - 0
pc-v3/src/components/crowdNameList/UploadAdjustDialog.vue

@@ -0,0 +1,205 @@
+<template>
+  <el-dialog
+    title="上传调整文件"
+    v-model="innerVisible"
+    width="640px"
+    :close-on-click-modal="false"
+    @close="reset"
+  >
+    <div class="format-block">
+      <div class="format-title">文件格式说明</div>
+      <div class="format-line">每行一条调整记录,使用 <code>|+|</code> 作为分隔符:</div>
+      <pre class="format-sample">客户号|+|调整方向
+00001|+|A
+00002|+|D</pre>
+      <div class="format-line">调整方向:<code>A</code> = 调增,<code>D</code> = 调减</div>
+      <div v-if="targetListLabel" class="format-line">本次调整目标:<code>{{ targetListLabel }}</code></div>
+    </div>
+
+    <el-upload
+      class="upload-area"
+      drag action="#"
+      :auto-upload="false"
+      :show-file-list="false"
+      :on-change="handleFile"
+      accept=".txt,.csv"
+    >
+      <el-icon><Upload /></el-icon>
+      <div class="el-upload__text">将文件拖到此处,或<em>点击选择文件</em></div>
+      <template #tip>
+        <div class="el-upload__tip">仅支持 .txt / .csv,建议 UTF-8 编码</div>
+      </template>
+    </el-upload>
+
+    <div v-if="rawLines.length > 0" class="preview">
+      <div class="preview-title">解析预览(共 {{ rawLines.length }} 行,有效 {{ validItems.length }} 行,无效 {{ invalidLines.length }} 行)</div>
+      <el-table :data="previewRows" border size="small" max-height="240">
+        <el-table-column prop="line" label="行号" width="60" align="center" />
+        <el-table-column prop="customerId" label="客户号" width="120" />
+        <el-table-column label="调整方向" width="110">
+          <template #default="scope">
+            {{ scope.row.direction === 'A' ? '调增-A' : scope.row.direction === 'D' ? '调减-D' : scope.row.direction }}
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" min-width="160">
+          <template #default="scope">
+            <el-tag v-if="scope.row.error" type="danger" size="small">{{ scope.row.error }}</el-tag>
+            <el-tag v-else type="success" size="small">有效</el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <div v-if="result" class="result-block">
+      <el-alert
+        :title="`处理完成:成功 ${result.success} 条,失败 ${result.failed} 条`"
+        :type="result.failed === 0 ? 'success' : 'warning'"
+        :closable="false"
+        show-icon
+      />
+      <div v-if="result.failures.length > 0" class="result-failures">
+        <div>失败明细:</div>
+        <ul>
+          <li v-for="(f, i) in result.failures" :key="i">
+            第 {{ f.line }} 行({{ f.item.customerId }} / {{ f.item.direction }}):{{ f.reason }}
+          </li>
+        </ul>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel">取 消</el-button>
+        <el-button
+          type="primary"
+          :disabled="validItems.length === 0"
+          @click="handleApply"
+        >确认上传({{ validItems.length }} 条)</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import crowdNameListStore from '@/store/crowdNameListStore'
+
+export default {
+  name: 'UploadAdjustDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    defaultListId: { type: String, default: '' }
+  },
+  emits: ['update:modelValue', 'applied'],
+  data() {
+    return {
+      rawLines: [],
+      previewRows: [],
+      result: null
+    }
+  },
+  computed: {
+    innerVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    validItems() {
+      return this.previewRows.filter((r) => !r.error)
+    },
+    invalidLines() {
+      return this.previewRows.filter((r) => r.error)
+    },
+    targetListLabel() {
+      if (!this.defaultListId) return ''
+      const l = crowdNameListStore.getById(this.defaultListId)
+      return l ? `${l.id}-${l.name}` : this.defaultListId
+    }
+  },
+  watch: {
+    modelValue(v) {
+      if (v) this.reset()
+    }
+  },
+  methods: {
+    reset() {
+      this.rawLines = []
+      this.previewRows = []
+      this.result = null
+    },
+    handleFile(file) {
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        const text = e.target.result || ''
+        const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l)
+        this.rawLines = lines
+        this.parseLines(lines)
+      }
+      reader.readAsText(file.raw, 'utf-8')
+    },
+    parseLines(lines) {
+      const listId = this.defaultListId
+      const list = listId ? crowdNameListStore.getById(listId) : null
+      this.previewRows = lines.map((line, idx) => {
+        const parts = line.split('|+|')
+        if (parts.length !== 2) {
+          return { line: idx + 1, raw: line, error: '格式错误(应为2段,使用 |+| 分隔)' }
+        }
+        const [customerId, direction] = parts.map((s) => s.trim())
+        if (!customerId) return { line: idx + 1, customerId, direction, error: '客户号为空' }
+        if (!list) return { line: idx + 1, customerId, direction, error: '目标客群不存在' }
+        if (list.status === 'stopped') return { line: idx + 1, customerId, direction, error: '名单已停止' }
+        if (direction !== 'A' && direction !== 'D') {
+          return { line: idx + 1, customerId, direction, error: '调整方向只能为 A 或 D' }
+        }
+        return { line: idx + 1, customerId, direction }
+      })
+    },
+    handleApply() {
+      if (this.validItems.length === 0) {
+        ElMessage.warning('没有有效记录可上传')
+        return
+      }
+      const items = this.validItems.map((r) => ({
+        listId: this.defaultListId,
+        customerId: r.customerId,
+        direction: r.direction
+      }))
+      this.result = crowdNameListStore.applyBatch(items)
+      ElMessage.success(`已提交 ${items.length} 条调整,审核通过后生效(demo 默认通过)`)
+      this.$emit('applied')
+    },
+    handleCancel() {
+      this.innerVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.format-block {
+  background: #f5f7fa; border-radius: 4px;
+  padding: 12px 16px; margin-bottom: 16px; font-size: 13px;
+}
+.format-title { font-weight: 600; color: #303133; margin-bottom: 6px; }
+.format-line { color: #606266; margin: 4px 0; }
+.format-line code { background: #fff; padding: 1px 4px; border-radius: 2px; color: #F56C6C; }
+.format-sample {
+  background: #fff; padding: 8px 12px; border: 1px solid #ebeef5; border-radius: 2px;
+  font-family: 'Menlo', 'Consolas', monospace; font-size: 12px;
+  margin: 6px 0; white-space: pre;
+}
+
+.upload-area { margin-bottom: 16px; }
+
+.preview { margin-top: 12px; }
+.preview-title { font-size: 13px; color: #606266; margin-bottom: 8px; }
+
+.result-block { margin-top: 16px; }
+.result-failures {
+  margin-top: 8px; font-size: 12px; color: #909399;
+  max-height: 120px; overflow-y: auto;
+}
+.result-failures ul { padding-left: 18px; margin: 4px 0; }
+
+.dialog-footer { text-align: center; }
+</style>

+ 135 - 0
pc-v3/src/components/crowdTask/AddRuleGroupDialog.vue

@@ -0,0 +1,135 @@
+<template>
+  <el-dialog
+    title="新增规则组"
+    v-model="innerVisible"
+    width="560px"
+    :close-on-click-modal="false"
+    @close="reset"
+  >
+    <el-form ref="form" :model="form" label-width="140px" label-position="right">
+      <el-form-item label="规则组卡种类型" required>
+        <el-select
+          v-model="form.cardType"
+          placeholder="请选择"
+          style="width: 100%"
+          @change="form.ruleGroupId = ''"
+        >
+          <el-option label="借记卡圈客规则" value="debitCard" />
+          <el-option label="信用卡圈客规则" value="creditCard" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="规则组" required>
+        <el-select
+          v-model="form.ruleGroupId"
+          placeholder="请选择"
+          filterable
+          :filter-method="filterRuleGroup"
+          style="width: 100%"
+          no-data-text="暂无可选规则组"
+          no-match-text="未找到匹配的规则组"
+        >
+          <el-option
+            v-for="rg in filteredGroups"
+            :key="rg.id"
+            :label="`${rg.id}(${rg.name})`"
+            :value="rg.id"
+          />
+        </el-select>
+        <div class="hint">仅展示「已上线」状态且与卡种类型匹配的规则组</div>
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel">取 消</el-button>
+        <el-button type="primary" @click="handleSave">保 存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import ruleGroupStore from '@/store/ruleGroupStore'
+
+export default {
+  name: 'AddRuleGroupDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    excludeIds: { type: Array, default: () => [] }
+  },
+  emits: ['update:modelValue', 'save'],
+  data() {
+    return {
+      form: { cardType: '', ruleGroupId: '' },
+      filterKeyword: ''
+    }
+  },
+  computed: {
+    innerVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    filteredGroups() {
+      const all = ruleGroupStore.list.filter((rg) => rg.status === 'online')
+      return all.filter((rg) => {
+        if (this.form.cardType && rg.type !== this.form.cardType) return false
+        if (this.excludeIds.includes(rg.id)) return false
+        if (this.filterKeyword) {
+          const k = this.filterKeyword.toLowerCase()
+          if (!rg.id.toLowerCase().includes(k) && !rg.name.toLowerCase().includes(k)) {
+            return false
+          }
+        }
+        return true
+      })
+    }
+  },
+  watch: {
+    modelValue(v) {
+      if (v) this.reset()
+    }
+  },
+  methods: {
+    reset() {
+      this.form = { cardType: '', ruleGroupId: '' }
+      this.filterKeyword = ''
+    },
+    filterRuleGroup(k) {
+      this.filterKeyword = k
+    },
+    handleSave() {
+      if (!this.form.cardType) {
+        ElMessage.error('请选择规则组卡种类型')
+        return
+      }
+      if (!this.form.ruleGroupId) {
+        ElMessage.error('请选择规则组')
+        return
+      }
+      const rg = ruleGroupStore.getById(this.form.ruleGroupId)
+      if (!rg) {
+        ElMessage.error('所选规则组不存在')
+        return
+      }
+      this.$emit('save', {
+        id: rg.id,
+        name: rg.name,
+        description: rg.description || '-',
+        logic: rg.logic,
+        ruleCount: (rg.rules || []).length
+      })
+      this.innerVisible = false
+    },
+    handleCancel() {
+      this.innerVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hint { color: #909399; font-size: 12px; margin-top: 4px; }
+.dialog-footer { text-align: center; }
+</style>

+ 43 - 0
pc-v3/src/components/crowdTask/SampleDetailDialog.vue

@@ -0,0 +1,43 @@
+<template>
+  <el-dialog
+    title="sample结果详情"
+    v-model="innerVisible"
+    width="520px"
+  >
+    <div class="dialog-body">
+      <span class="label"><span class="required">*</span> sample 结果</span>
+      <el-table :data="rows" border stripe size="small" style="margin-top: 8px">
+        <el-table-column prop="index" label="序号" width="100" align="center" />
+        <el-table-column prop="customerId" label="客户号" align="center" />
+      </el-table>
+    </div>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button type="primary" @click="innerVisible = false">返 回</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'SampleDetailDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    rows: { type: Array, default: () => [] }
+  },
+  emits: ['update:modelValue'],
+  computed: {
+    innerVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dialog-body .label { font-weight: 600; color: #303133; }
+.required { color: #F56C6C; margin-right: 4px; }
+.dialog-footer { text-align: center; }
+</style>

+ 272 - 0
pc-v3/src/components/ruleGroup/RuleEditDialog.vue

@@ -0,0 +1,272 @@
+<template>
+  <el-dialog
+    :title="dialogTitle"
+    v-model="innerVisible"
+    width="640px"
+    :close-on-click-modal="false"
+    @close="handleCancel"
+  >
+    <el-form
+      ref="form"
+      :model="form"
+      label-width="140px"
+      label-position="right"
+    >
+      <el-form-item label="元标签规则" required>
+        <el-select
+          v-model="form.metaCode"
+          placeholder="请选择"
+          :disabled="readonly"
+          style="width: 100%"
+          @change="handleMetaChange"
+        >
+          <el-option
+            v-for="meta in metaRules"
+            :key="meta.code"
+            :label="`${meta.code}-${meta.name}`"
+            :value="meta.code"
+          />
+        </el-select>
+      </el-form-item>
+
+      <template v-if="currentMeta">
+        <el-divider />
+
+        <el-form-item label="规则名称">
+          <el-input
+            v-model="form.ruleName"
+            :disabled="readonly"
+            placeholder="便于识别该规则用途"
+            maxlength="40"
+            show-word-limit
+            style="width: 100%"
+          />
+        </el-form-item>
+
+        <el-form-item
+          v-for="field in currentMeta.fields"
+          :key="field.key"
+          :label="field.label"
+          :required="field.required"
+        >
+          <!-- 单选 -->
+          <el-select
+            v-if="field.type === 'select'"
+            v-model="form.config[field.key]"
+            :disabled="readonly"
+            placeholder="请选择"
+            clearable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="opt in field.options"
+              :key="String(opt.value)"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+          <!-- 多选 -->
+          <el-select
+            v-else-if="field.type === 'multiSelect'"
+            v-model="form.config[field.key]"
+            :disabled="readonly"
+            multiple
+            collapse-tags
+            placeholder="请选择"
+            clearable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="opt in field.options"
+              :key="String(opt.value)"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+          <!-- 日期 -->
+          <el-date-picker
+            v-else-if="field.type === 'datePicker'"
+            v-model="form.config[field.key]"
+            :disabled="readonly"
+            type="date"
+            placeholder="请选择日期"
+            value-format="yyyy-MM-dd"
+            style="width: 100%"
+          />
+          <!-- 月份 -->
+          <el-date-picker
+            v-else-if="field.type === 'monthPicker'"
+            v-model="form.config[field.key]"
+            :disabled="readonly"
+            type="month"
+            placeholder="请选择日期"
+            value-format="yyyy-MM"
+            style="width: 100%"
+          />
+          <!-- 数字 -->
+          <el-input
+            v-else-if="field.type === 'number'"
+            v-model.number="form.config[field.key]"
+            :disabled="readonly"
+            type="number"
+            :min="field.min"
+            :max="field.max"
+            placeholder="请输入"
+            style="width: 100%"
+          />
+          <!-- 连续达标月数 / 时间范围 -->
+          <div v-else-if="field.type === 'nm'" class="nm-input">
+            <el-input
+              v-model.number="form.config[field.key].n"
+              :disabled="readonly"
+              type="number"
+              :min="1"
+              style="width: 90px"
+            />
+            <span class="nm-sep">/</span>
+            <el-input
+              v-model.number="form.config[field.key].m"
+              :disabled="readonly"
+              type="number"
+              :min="1"
+              style="width: 90px"
+            />
+          </div>
+          <!-- 文本 -->
+          <el-input
+            v-else
+            v-model="form.config[field.key]"
+            :disabled="readonly"
+            placeholder="请输入"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </template>
+    </el-form>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel">取 消</el-button>
+        <el-button v-if="!readonly" type="primary" @click="handleSave">保 存</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import { META_RULES, getMetaRule } from '@/config/metaRules'
+
+const emptyForm = () => ({
+  id: '',
+  metaCode: '',
+  ruleName: '',
+  config: {}
+})
+
+export default {
+  name: 'RuleEditDialog',
+  props: {
+    modelValue: { type: Boolean, default: false },
+    rule: { type: Object, default: null },
+    mode: { type: String, default: 'add' } // add | edit | view
+  },
+  emits: ['update:modelValue', 'save', 'cancel'],
+  data() {
+    return {
+      form: emptyForm(),
+      metaRules: META_RULES
+    }
+  },
+  computed: {
+    innerVisible: {
+      get() { return this.modelValue },
+      set(v) { this.$emit('update:modelValue', v) }
+    },
+    dialogTitle() {
+      if (this.mode === 'edit') return '编辑规则信息'
+      if (this.mode === 'view') return '查看规则信息'
+      return '新增规则信息'
+    },
+    readonly() {
+      return this.mode === 'view'
+    },
+    currentMeta() {
+      return this.form.metaCode ? getMetaRule(this.form.metaCode) : null
+    }
+  },
+  watch: {
+    modelValue(v) {
+      if (v) this.initForm()
+    }
+  },
+  methods: {
+    initForm() {
+      if (this.rule) {
+        this.form = {
+          id: this.rule.id,
+          metaCode: this.rule.metaCode,
+          ruleName: this.rule.ruleName || '',
+          config: JSON.parse(JSON.stringify(this.rule.config || {}))
+        }
+      } else {
+        this.form = emptyForm()
+      }
+    },
+    handleMetaChange(code) {
+      const meta = getMetaRule(code)
+      const config = {}
+      if (meta) {
+        meta.fields.forEach((f) => {
+          if (f.type === 'multiSelect') config[f.key] = []
+          else if (f.type === 'nm') config[f.key] = f.defaultValue ? { ...f.defaultValue } : { n: 3, m: 6 }
+          else config[f.key] = ''
+        })
+      }
+      this.form.config = config
+      if (!this.form.ruleName && meta) {
+        this.form.ruleName = meta.name
+      }
+    },
+    validateConfig() {
+      if (!this.form.metaCode) {
+        ElMessage.error('请选择元标签规则')
+        return false
+      }
+      const meta = this.currentMeta
+      if (!meta) return false
+      for (const f of meta.fields) {
+        if (!f.required) continue
+        const v = this.form.config[f.key]
+        const empty = v === '' || v === null || v === undefined ||
+          (Array.isArray(v) && v.length === 0)
+        if (empty) {
+          ElMessage.error(`请填写「${f.label}」`)
+          return false
+        }
+      }
+      return true
+    },
+    handleSave() {
+      if (!this.validateConfig()) return
+      this.$emit('save', {
+        id: this.form.id || `r_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
+        metaCode: this.form.metaCode,
+        ruleName: this.form.ruleName || this.currentMeta.name,
+        config: JSON.parse(JSON.stringify(this.form.config))
+      })
+      this.innerVisible = false
+    },
+    handleCancel() {
+      this.innerVisible = false
+      this.$emit('cancel')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.nm-input { display: flex; align-items: center; }
+.nm-sep { margin: 0 12px; color: #303133; font-size: 16px; }
+.dialog-footer { text-align: center; }
+</style>

+ 547 - 0
pc-v3/src/components/tag/RuleBuilder.vue

@@ -0,0 +1,547 @@
+<template>
+  <div class="rule-builder">
+    <div class="toolbar">
+      <span class="rule-tip">规则组之间为 <b>且</b>;组内关系可切换 且/或</span>
+      <el-button
+        v-if="!readonly"
+        type="primary"
+        size="small"
+        plain
+        @click="addGroup"
+      ><el-icon><Plus /></el-icon>新增规则组</el-button>
+    </div>
+
+    <div
+      v-for="(group, gIndex) in groups"
+      :key="group.id"
+      class="rule-group"
+    >
+      <div class="group-header">
+        <div class="group-title">
+          <span class="group-no">规则组 {{ gIndex + 1 }}</span>
+          <span class="group-logic">
+            组内关系:
+            <el-radio-group
+              v-model="group.logic"
+              size="small"
+              :disabled="readonly"
+            >
+              <el-radio-button label="AND">且</el-radio-button>
+              <el-radio-button label="OR">或</el-radio-button>
+            </el-radio-group>
+          </span>
+        </div>
+        <div v-if="!readonly" class="group-actions">
+          <el-button size="small" @click="copyGroup(gIndex)"><el-icon><DocumentCopy /></el-icon>复制</el-button>
+          <el-button
+            size="small"
+            type="danger"
+            plain
+            :disabled="groups.length <= 1"
+            @click="removeGroup(gIndex)"
+          ><el-icon><Delete /></el-icon>删除</el-button>
+        </div>
+      </div>
+
+      <div class="rule-list">
+        <div
+          v-for="(rule, rIndex) in group.rules"
+          :key="rule.id"
+          class="rule-row"
+        >
+          <span class="rule-no">{{ rIndex + 1 }}</span>
+
+          <!-- 参数选择 -->
+          <el-select
+            v-model="rule.paramKey"
+            placeholder="选择参数"
+            filterable
+            size="small"
+            style="width: 240px"
+            :disabled="readonly"
+            @change="onParamChange(rule)"
+          >
+            <el-option-group
+              v-for="grp in paramGroups"
+              :key="grp.key"
+              :label="grp.label"
+            >
+              <el-option
+                v-for="p in grp.params"
+                :key="p.key"
+                :label="p.label"
+                :value="p.key"
+              />
+            </el-option-group>
+          </el-select>
+
+          <!-- 时间窗口(仅历史值参数显示) -->
+          <template v-if="showTimeWindow(rule)">
+            <el-select
+              v-model="rule.timeWindow.mode"
+              size="small"
+              style="width: 120px"
+              :disabled="readonly"
+              @change="onTimeModeChange(rule)"
+            >
+              <el-option
+                v-for="m in timeWindowModes"
+                :key="m.value"
+                :label="m.label"
+                :value="m.value"
+              />
+            </el-select>
+
+            <el-date-picker
+              v-if="rule.timeWindow.mode === 'specificMonth'"
+              v-model="rule.timeWindow.month"
+              type="month"
+              size="small"
+              style="width: 140px"
+              placeholder="选择月份"
+              value-format="yyyy-MM"
+              :disabled="readonly"
+            />
+
+            <template v-if="rule.timeWindow.mode === 'recentMonths'">
+              <el-input-number
+                v-model="rule.timeWindow.n"
+                :min="1"
+                :max="12"
+                size="small"
+                style="width: 110px"
+                :disabled="readonly"
+              />
+              <span class="inline-label">个月</span>
+              <el-select
+                v-model="rule.timeWindow.aggregator"
+                size="small"
+                style="width: 140px"
+                :disabled="readonly"
+              >
+                <el-option
+                  v-for="a in aggregatorsFor(rule)"
+                  :key="a.value"
+                  :label="a.label"
+                  :value="a.value"
+                />
+              </el-select>
+              <el-input-number
+                v-if="rule.timeWindow.aggregator === 'atLeast'"
+                v-model="rule.timeWindow.m"
+                :min="1"
+                :max="rule.timeWindow.n || 12"
+                size="small"
+                style="width: 90px"
+                :disabled="readonly"
+              />
+              <span v-if="rule.timeWindow.aggregator === 'atLeast'" class="inline-label">个月</span>
+            </template>
+          </template>
+
+          <!-- 运算符 -->
+          <el-select
+            v-model="rule.operator"
+            size="small"
+            style="width: 140px"
+            placeholder="运算符"
+            :disabled="!rule.paramKey || readonly"
+            @change="onOperatorChange(rule)"
+          >
+            <el-option
+              v-for="op in operatorsFor(rule)"
+              :key="op.value"
+              :label="op.label"
+              :value="op.value"
+            />
+          </el-select>
+
+          <!-- 比较值:根据 operator & param 动态切换 -->
+          <component
+            :is="valueComponentFor(rule).is"
+            v-if="needValue(rule)"
+            v-bind="valueComponentFor(rule).props"
+            v-model="rule.value"
+            :disabled="readonly"
+          >
+            <template v-if="valueComponentFor(rule).options">
+              <el-option
+                v-for="opt in valueComponentFor(rule).options"
+                :key="opt.value"
+                :label="opt.label"
+                :value="opt.value"
+              />
+            </template>
+          </component>
+
+          <!-- 区间 value2 -->
+          <template v-if="rule.operator === 'between' && needValue(rule)">
+            <span class="inline-label">~</span>
+            <component
+              :is="valueComponentFor(rule).is"
+              v-bind="valueComponentFor(rule).props"
+              v-model="rule.value2"
+              :disabled="readonly"
+            />
+          </template>
+
+          <!-- 相对日期:N 日/月/年内 -->
+          <template v-if="['withinLast', 'beforeLast'].includes(rule.operator)">
+            <el-select
+              v-model="rule.unit"
+              size="small"
+              style="width: 90px"
+              :disabled="readonly"
+            >
+              <el-option label="日" value="day" />
+              <el-option label="月" value="month" />
+              <el-option label="年" value="year" />
+            </el-select>
+          </template>
+
+          <!-- 删除规则按钮 -->
+          <el-button
+            v-if="!readonly"
+            class="rule-delete"
+            size="small"
+            type="text"
+            :disabled="group.rules.length <= 1"
+            @click="removeRule(gIndex, rIndex)"
+          ><el-icon><Close /></el-icon></el-button>
+        </div>
+
+        <div v-if="!readonly" class="rule-add">
+          <el-button
+            size="small"
+            plain
+            @click="addRule(gIndex)"
+          ><el-icon><Plus /></el-icon>新增规则</el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { ElMessageBox } from 'element-plus'
+import {
+  PARAM_GROUPS,
+  PARAM_MAP,
+  PARAM_TYPE,
+  TIME_WINDOW_MODES,
+  AGGREGATORS_ENUM,
+  AGGREGATORS_NUMBER,
+  BOOLEAN_OPTIONS,
+  getOperatorsByParam,
+  needTimeWindow,
+  needValueInput
+} from '@/config/tagParams'
+
+let uid = 0
+const nextId = () => `rb_${Date.now()}_${uid++}`
+
+function makeEmptyRule() {
+  return {
+    id: nextId(),
+    paramKey: '',
+    timeWindow: {
+      mode: 'current',
+      month: '',
+      n: 3,
+      m: 1,
+      aggregator: 'any'
+    },
+    operator: '',
+    value: '',
+    value2: '',
+    unit: 'day'
+  }
+}
+
+function makeEmptyGroup() {
+  return {
+    id: nextId(),
+    logic: 'AND',
+    rules: [makeEmptyRule()]
+  }
+}
+
+export default {
+  name: 'RuleBuilder',
+  props: {
+    modelValue: {
+      type: Array,
+      default: () => []
+    },
+    readonly: {
+      type: Boolean,
+      default: false
+    }
+  },
+  emits: ['update:modelValue', 'change'],
+  data() {
+    return {
+      paramGroups: PARAM_GROUPS,
+      timeWindowModes: TIME_WINDOW_MODES,
+      groups: []
+    }
+  },
+  created() {
+    if (this.modelValue && this.modelValue.length) {
+      this.groups = this.cloneGroups(this.modelValue)
+    } else {
+      this.groups = [makeEmptyGroup()]
+    }
+    // 初始化后把规范化后的 groups 同步回父组件一次,
+    // 避免父组件持有与子组件引用不一致的数组导致后续 v-model 回流。
+    this.$nextTick(() => {
+      this.$emit('update:modelValue', this.groups)
+    })
+  },
+  watch: {
+    groups: {
+      deep: true,
+      handler(val) {
+        this.$emit('update:modelValue', val)
+        this.$emit('change', val)
+      }
+    }
+  },
+  methods: {
+    cloneGroups(val) {
+      return val.map((g) => ({
+        id: g.id || nextId(),
+        logic: g.logic || 'AND',
+        rules: (g.rules || []).map((r) => ({
+          ...makeEmptyRule(),
+          ...r,
+          id: r.id || nextId(),
+          timeWindow: {
+            ...makeEmptyRule().timeWindow,
+            ...(r.timeWindow || {})
+          }
+        }))
+      }))
+    },
+    addGroup() {
+      this.groups.push(makeEmptyGroup())
+    },
+    removeGroup(index) {
+      if (this.groups.length <= 1) return
+      ElMessageBox.confirm('确定删除该规则组?', '提示', { type: 'warning' })
+        .then(() => this.groups.splice(index, 1))
+        .catch(() => {})
+    },
+    copyGroup(index) {
+      const src = this.groups[index]
+      const copy = JSON.parse(JSON.stringify(src))
+      copy.id = nextId()
+      copy.rules = copy.rules.map((r) => ({ ...r, id: nextId() }))
+      this.groups.splice(index + 1, 0, copy)
+    },
+    addRule(gIndex) {
+      this.groups[gIndex].rules.push(makeEmptyRule())
+    },
+    removeRule(gIndex, rIndex) {
+      const group = this.groups[gIndex]
+      if (group.rules.length <= 1) return
+      group.rules.splice(rIndex, 1)
+    },
+    onParamChange(rule) {
+      rule.operator = ''
+      rule.value = ''
+      rule.value2 = ''
+      const param = PARAM_MAP[rule.paramKey]
+      if (!param || !param.hasHistory) {
+        rule.timeWindow = makeEmptyRule().timeWindow
+      }
+    },
+    onTimeModeChange(rule) {
+      if (rule.timeWindow.mode !== 'recentMonths') {
+        rule.timeWindow.aggregator = 'any'
+      }
+    },
+    onOperatorChange(rule) {
+      rule.value = ''
+      rule.value2 = ''
+    },
+    showTimeWindow(rule) {
+      return needTimeWindow(PARAM_MAP[rule.paramKey])
+    },
+    aggregatorsFor(rule) {
+      const param = PARAM_MAP[rule.paramKey]
+      if (!param) return AGGREGATORS_ENUM
+      if (param.type === PARAM_TYPE.NUMBER) return AGGREGATORS_NUMBER
+      if (param.type === PARAM_TYPE.LIST && param.listItemType === PARAM_TYPE.NUMBER) return AGGREGATORS_NUMBER
+      return AGGREGATORS_ENUM
+    },
+    operatorsFor(rule) {
+      return getOperatorsByParam(PARAM_MAP[rule.paramKey])
+    },
+    needValue(rule) {
+      if (!rule.operator) return false
+      return needValueInput(rule.operator)
+    },
+    valueComponentFor(rule) {
+      const param = PARAM_MAP[rule.paramKey]
+      if (!param) return { is: 'el-input', props: { size: 'small', style: 'width: 180px', placeholder: '比较值' } }
+
+      // 推荐关系
+      if (param.type === PARAM_TYPE.REFERRAL) {
+        if (['includesCustomerId', 'notIncludesCustomerId'].includes(rule.operator)) {
+          return {
+            is: 'el-input',
+            props: { size: 'small', style: 'width: 220px', placeholder: '客户号,多个英文逗号分隔' }
+          }
+        }
+        return {
+          is: 'el-input-number',
+          props: { size: 'small', style: 'width: 140px', min: 0, controlsPosition: 'right' }
+        }
+      }
+
+      // Boolean
+      if (param.type === PARAM_TYPE.BOOLEAN) {
+        return {
+          is: 'el-select',
+          props: { size: 'small', style: 'width: 120px', placeholder: '请选择' },
+          options: BOOLEAN_OPTIONS
+        }
+      }
+
+      // Date
+      if (param.type === PARAM_TYPE.DATE) {
+        if (['withinLast', 'beforeLast'].includes(rule.operator)) {
+          return {
+            is: 'el-input-number',
+            props: { size: 'small', style: 'width: 120px', min: 1, controlsPosition: 'right' }
+          }
+        }
+        const isMonth = param.dateFormat === 'YYYY-MM'
+        return {
+          is: 'el-date-picker',
+          props: {
+            type: isMonth ? 'month' : 'date',
+            size: 'small',
+            style: 'width: 160px',
+            placeholder: '选择日期',
+            valueFormat: isMonth ? 'yyyy-MM' : 'yyyy-MM-dd'
+          }
+        }
+      }
+
+      // 多选枚举
+      if (param.multiple && param.options) {
+        return {
+          is: 'el-select',
+          props: {
+            multiple: true,
+            collapseTags: true,
+            size: 'small',
+            style: 'width: 280px',
+            placeholder: '请选择'
+          },
+          options: param.options
+        }
+      }
+
+      // 枚举(单选 或 in/notIn 变多选)
+      if (param.options) {
+        if (['in', 'notIn'].includes(rule.operator)) {
+          return {
+            is: 'el-select',
+            props: {
+              multiple: true,
+              collapseTags: true,
+              size: 'small',
+              style: 'width: 260px',
+              placeholder: '请选择'
+            },
+            options: param.options
+          }
+        }
+        return {
+          is: 'el-select',
+          props: { size: 'small', style: 'width: 200px', placeholder: '请选择' },
+          options: param.options
+        }
+      }
+
+      // 数值
+      if (param.type === PARAM_TYPE.NUMBER || param.type === PARAM_TYPE.LIST) {
+        return {
+          is: 'el-input-number',
+          props: { size: 'small', style: 'width: 160px', controlsPosition: 'right' }
+        }
+      }
+
+      // 默认文本
+      return {
+        is: 'el-input',
+        props: { size: 'small', style: 'width: 200px', placeholder: '比较值' }
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.rule-builder { display: flex; flex-direction: column; gap: 16px; }
+
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.rule-tip { color: #909399; font-size: 13px; }
+.rule-tip b { color: #F56C6C; margin: 0 2px; }
+
+.rule-group {
+  border: 1px solid #ebeef5;
+  border-radius: 6px;
+  background: #fafbfc;
+}
+.group-header {
+  padding: 10px 16px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px dashed #ebeef5;
+  background: #f4f6f8;
+  border-radius: 6px 6px 0 0;
+}
+.group-title { display: flex; align-items: center; gap: 16px; }
+.group-no { font-weight: 600; color: #303133; }
+.group-logic { color: #606266; font-size: 13px; display: flex; align-items: center; gap: 8px; }
+.group-actions { display: flex; gap: 8px; }
+
+.rule-list { padding: 12px 16px; }
+
+.rule-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 8px 0;
+  border-bottom: 1px dashed #f0f2f5;
+}
+.rule-row:last-of-type { border-bottom: none; }
+
+.rule-no {
+  width: 24px;
+  height: 24px;
+  line-height: 24px;
+  text-align: center;
+  background: #ecf5ff;
+  color: #409EFF;
+  border-radius: 50%;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+
+.inline-label { color: #606266; font-size: 13px; }
+
+.rule-delete { margin-left: auto; color: #F56C6C; }
+
+.rule-add { padding-top: 8px; }
+</style>

+ 200 - 0
pc-v3/src/config/metaRules.js

@@ -0,0 +1,200 @@
+// 元规则(Meta Rule)字典
+// 每个元规则定义编号、名称、配置字段,并提供 buildDescription / buildParams
+// 用于在规则列表中自动渲染"规则描述"和"规则参数"
+
+// 规则组类型选项
+export const GROUP_TYPES = [
+  { value: 'debitCard', label: '借记卡圈客规则' },
+  { value: 'creditCard', label: '信用卡圈客规则' }
+]
+
+// 规则逻辑
+export const LOGIC_OPTIONS = [
+  { value: 'AND', label: '且(AND)' },
+  { value: 'OR', label: '或(OR)' }
+]
+
+// 状态
+export const STATUS_OPTIONS = [
+  { value: 'draft', label: '新建' },
+  { value: 'online', label: '已上线' }
+]
+
+// 常用比较运算符(用于 TRB / 余额 / 年龄等数值字段)
+const NUMERIC_OPS = [
+  { value: '>', label: '大于 >' },
+  { value: '<', label: '小于 <' },
+  { value: '=', label: '等于 =' },
+  { value: '>=', label: '大于等于 >=' },
+  { value: '<=', label: '小于等于 <=' },
+  { value: '!=', label: '不等于 !=' }
+]
+
+// 日期比较运算符
+const DATE_OPS = [
+  { value: '<', label: '在...前' },
+  { value: '>', label: '在...后' },
+  { value: '=', label: '在...当日' }
+]
+
+// 集合运算符
+const SET_OPS = [
+  { value: 'in', label: '属于' },
+  { value: 'not_in', label: '不属于' },
+  { value: '=', label: '等于' },
+  { value: '!=', label: '不等于' }
+]
+
+// =============================================
+// 工具函数
+// =============================================
+function formatMonthCN(yyyymm) {
+  if (!yyyymm) return ''
+  const [y, m] = yyyymm.split('-')
+  return `${y}年${parseInt(m, 10)}月`
+}
+function formatMonthCompact(yyyymm) {
+  return yyyymm ? yyyymm.replace('-', '') : ''
+}
+function formatDateSlash(yyyymmdd) {
+  if (!yyyymmdd) return ''
+  const [y, m, d] = yyyymmdd.split('-')
+  return `${y}/${parseInt(m, 10)}/${parseInt(d, 10)}`
+}
+function formatAmount(num) {
+  if (num === '' || num === null || num === undefined) return ''
+  const n = Number(num)
+  if (Number.isNaN(n)) return String(num)
+  if (n >= 10000) return `${n / 10000}万`
+  return String(n)
+}
+function dateOpSuffix(op) {
+  return { '<': '前', '>': '后', '=': '当日' }[op] || op || ''
+}
+
+// =============================================
+// 元规则列表
+// =============================================
+export const META_RULES = [
+  {
+    code: '001',
+    name: '开户时间(open_date)',
+    paramKey: 'open_date',
+    fields: [
+      {
+        key: 'accountType', label: '账户类型', type: 'select', required: true,
+        options: [
+          { value: 'I', label: 'I类账户' },
+          { value: 'II', label: 'II类账户' },
+          { value: 'III', label: 'III类账户' }
+        ]
+      },
+      { key: 'operator', label: '比较方式', type: 'select', required: true, options: DATE_OPS },
+      { key: 'date', label: '开户时间', type: 'datePicker', required: true }
+    ],
+    buildDescription(c) {
+      if (!c.accountType || !c.date) return ''
+      return `开户时间(${c.accountType}类账户)在${formatDateSlash(c.date)}${dateOpSuffix(c.operator)}`
+    },
+    buildParams(c) {
+      if (!c.operator || !c.date) return ''
+      return `{open_date,${c.operator},(${c.date})}`
+    }
+  },
+  {
+    code: '010',
+    name: 'MSC',
+    paramKey: 'MSC',
+    fields: [
+      { key: 'operator', label: 'MSC表达式', type: 'select', required: true, options: SET_OPS },
+      {
+        key: 'values', label: 'MSC值', type: 'multiSelect', required: true,
+        options: [
+          { value: 'premier', label: 'premier(卓越理财)' },
+          { value: 'advance', label: 'advance(运筹理财)' },
+          { value: 'excellence', label: 'excellence(卓越理财·尊尚)' },
+          { value: 'global', label: 'global(环球私人银行)' }
+        ]
+      }
+    ],
+    buildDescription(c) {
+      const vals = (c.values || []).join('/')
+      if (!vals) return ''
+      const opText = c.operator === 'not_in' || c.operator === '!=' ? '≠' : '='
+      return `MSC${opText}${vals}`
+    },
+    buildParams(c) {
+      const vals = (c.values || []).join('/')
+      if (!c.operator || !vals) return ''
+      return `{MSC,${c.operator},(${vals})}`
+    }
+  },
+  {
+    code: '050',
+    name: 'TRB值-Avg TRB口径(不含家庭账户余额)',
+    paramKey: 'Avg_TRB',
+    fields: [
+      { key: 'month', label: '开始月份', type: 'monthPicker', required: true },
+      { key: 'nm', label: '连续达标月数/时间范围(月)', type: 'nm', required: false, defaultValue: { n: 3, m: 6 } },
+      { key: 'operator', label: 'Avg TRB表达式', type: 'select', required: true, options: NUMERIC_OPS },
+      { key: 'amount', label: 'Avg TRB值', type: 'number', required: true, min: 0 }
+    ],
+    buildDescription(c) {
+      if (!c.month || !c.operator || c.amount === '' || c.amount == null) return ''
+      const n = c.nm && c.nm.n ? `(连续${c.nm.n}个月达标)` : ''
+      return `${formatMonthCN(c.month)}月末 TRB值-Avg TRB口径(不含家庭账户余额)${c.operator}${formatAmount(c.amount)}${n}`
+    },
+    buildParams(c) {
+      if (!c.month || !c.operator) return ''
+      const month = formatMonthCompact(c.month)
+      const nm = c.nm && c.nm.n ? `;{${c.nm.n}/${c.nm.m || ''}}` : ''
+      return `{month,=,${month}};{Avg_TRB,${c.operator},${c.amount}}${nm}`
+    }
+  },
+  {
+    code: '051',
+    name: '月末余额(End of day balance)',
+    paramKey: 'balance',
+    fields: [
+      { key: 'month', label: '开始月份', type: 'monthPicker', required: true },
+      { key: 'nm', label: '连续达标月数/时间范围(月)', type: 'nm', required: false, defaultValue: { n: 3, m: 6 } },
+      { key: 'operator', label: '余额表达式', type: 'select', required: true, options: NUMERIC_OPS },
+      { key: 'amount', label: '余额值', type: 'number', required: true, min: 0 }
+    ],
+    buildDescription(c) {
+      if (!c.month || !c.operator || c.amount === '' || c.amount == null) return ''
+      const n = c.nm && c.nm.n ? `(连续${c.nm.n}个月达标)` : ''
+      return `${formatMonthCN(c.month)}月末 End of day balance (所有资金含产品)${c.operator}${formatAmount(c.amount)}${n}`
+    },
+    buildParams(c) {
+      if (!c.month || !c.operator) return ''
+      const month = formatMonthCompact(c.month)
+      const nm = c.nm && c.nm.n ? `;{${c.nm.n}/${c.nm.m || ''}}` : ''
+      return `{month,=,${month}};{balance,${c.operator},${c.amount}}${nm}`
+    }
+  }
+]
+
+// 元规则下拉显示文本:编号-名称
+export function metaRuleLabel(meta) {
+  return `${meta.code}-${meta.name}`
+}
+
+// 根据 code 查询元规则
+export function getMetaRule(code) {
+  return META_RULES.find((m) => m.code === code)
+}
+
+// 计算一条规则的描述
+export function buildRuleDescription(metaCode, config) {
+  const meta = getMetaRule(metaCode)
+  if (!meta || !meta.buildDescription) return ''
+  return meta.buildDescription(config || {})
+}
+
+// 计算一条规则的参数
+export function buildRuleParams(metaCode, config) {
+  const meta = getMetaRule(metaCode)
+  if (!meta || !meta.buildParams) return ''
+  return meta.buildParams(config || {})
+}

+ 297 - 0
pc-v3/src/config/tagParams.js

@@ -0,0 +1,297 @@
+// 标签参数清单 —— 与《数据标签需求》附录 A 对齐
+// 每个参数字段说明:
+//   key           参数名(英文)
+//   label         中文展示名
+//   type          String | BigDecimal | Boolean | Date | List | ReferralList
+//   hasHistory    是否存在"近 12 个月月末值"变体(List 变体)
+//   dateFormat    日期参数格式:YYYY-MM-DD | YYYY-MM(仅 Date 类型)
+//   options       枚举取值(String 枚举或多选枚举时使用)
+//   multiple      是否为多选枚举(多个枚举值以英文逗号分隔)
+//   listItemType  List 元素类型(仅 List 类型使用),用于决定聚合方式(数值可均值/极值,枚举仅计数)
+//   desc          补充说明
+
+export const PARAM_TYPE = {
+  STRING: 'String',
+  NUMBER: 'BigDecimal',
+  BOOLEAN: 'Boolean',
+  DATE: 'Date',
+  LIST: 'List',
+  REFERRAL: 'ReferralList'
+}
+
+// 运算符清单,按类型分组
+export const OPERATORS = {
+  // String 自由文本
+  STRING_TEXT: [
+    { value: 'eq', label: '等于' },
+    { value: 'neq', label: '不等于' },
+    { value: 'contains', label: '包含' },
+    { value: 'notContains', label: '不包含' },
+    { value: 'isNull', label: '为空' },
+    { value: 'isNotNull', label: '不为空' }
+  ],
+  // String 枚举
+  STRING_ENUM: [
+    { value: 'eq', label: '等于' },
+    { value: 'neq', label: '不等于' },
+    { value: 'in', label: '属于' },
+    { value: 'notIn', label: '不属于' },
+    { value: 'isNull', label: '为空' },
+    { value: 'isNotNull', label: '不为空' }
+  ],
+  // 数值
+  NUMBER: [
+    { value: 'eq', label: '=' },
+    { value: 'neq', label: '≠' },
+    { value: 'gt', label: '>' },
+    { value: 'gte', label: '≥' },
+    { value: 'lt', label: '<' },
+    { value: 'lte', label: '≤' },
+    { value: 'between', label: '区间' },
+    { value: 'isNull', label: '为空' },
+    { value: 'isNotNull', label: '不为空' }
+  ],
+  // 布尔
+  BOOLEAN: [
+    { value: 'eq', label: '=' },
+    { value: 'neq', label: '≠' }
+  ],
+  // 日期
+  DATE: [
+    { value: 'eq', label: '=' },
+    { value: 'neq', label: '≠' },
+    { value: 'gt', label: '晚于' },
+    { value: 'gte', label: '不早于' },
+    { value: 'lt', label: '早于' },
+    { value: 'lte', label: '不晚于' },
+    { value: 'between', label: '区间' },
+    { value: 'withinLast', label: '最近 N 日/月/年内' },
+    { value: 'beforeLast', label: '超过 N 日/月/年' },
+    { value: 'isNull', label: '为空' },
+    { value: 'isNotNull', label: '不为空' }
+  ]
+}
+
+// 时间窗口 - 取值方式
+export const TIME_WINDOW_MODES = [
+  { value: 'current', label: '当前值' },
+  { value: 'specificMonth', label: '指定某月' },
+  { value: 'recentMonths', label: '近 N 个月' }
+]
+
+// 时间窗口 - 聚合方式(仅取值方式=近 N 个月 时使用)
+export const AGGREGATORS_ENUM = [
+  { value: 'any', label: '任一月满足' },
+  { value: 'all', label: '所有月都满足' },
+  { value: 'atLeast', label: '至少 M 个月满足' }
+]
+
+export const AGGREGATORS_NUMBER = [
+  ...AGGREGATORS_ENUM,
+  { value: 'avg', label: '平均值' },
+  { value: 'max', label: '最大值' },
+  { value: 'min', label: '最小值' }
+]
+
+// 枚举字典
+export const ENUMS = {
+  privateBankTag: [
+    { value: '银辉', label: '银辉' },
+    { value: '金曜', label: '金曜' },
+    { value: '钻皓私人银行客户', label: '钻皓私人银行客户' }
+  ],
+  validRpq: [
+    { value: '0', label: '0 级' },
+    { value: '1', label: '1 级' },
+    { value: '2', label: '2 级' },
+    { value: '3', label: '3 级' },
+    { value: '4', label: '4 级' },
+    { value: '5', label: '5 级' },
+    { value: 'NA', label: 'NA' }
+  ],
+  assetCategoryDetails: [
+    { value: '1', label: '1 存款(定期/通知/个人大额存单)' },
+    { value: '2', label: '2 结构性存款(自有)' },
+    { value: '3', label: '3 基金相关产品' },
+    { value: '4', label: '4 理财产品(代销)' },
+    { value: '5', label: '5 代客境外理财海外债券型/结构性票据型(自有)' },
+    { value: '6', label: '6 资产管理计划(代销)' },
+    { value: '7', label: '7 信托计划(代销)' },
+    { value: '8', label: '8 保险产品(代销)' },
+    { value: '9', label: '9 预留' }
+  ],
+  fixedDepositTypes: [
+    { value: '1', label: '1 不持有定存' },
+    { value: '2', label: '2 持有普通定存(美金)' },
+    { value: '3', label: '3 持有普通定存(人民币)' },
+    { value: '4', label: '4 持有普通定存(其他币种)' },
+    { value: '5', label: '5 持有大额存单(人民币)' }
+  ]
+}
+
+// 参数分组及清单
+export const PARAM_GROUPS = [
+  {
+    key: 'basic',
+    label: '客户基础与身份',
+    params: [
+      { key: 'mscGroup', label: '客户 MSC Group', type: PARAM_TYPE.STRING, hasHistory: true },
+      { key: 'mscCode', label: '客户类型 MSC', type: PARAM_TYPE.STRING, hasHistory: true },
+      { key: 'currentMscEntryDate', label: 'Current MSC entry date', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM-DD' },
+      { key: 'mob', label: '客户 MOB 值(含信用卡)', type: PARAM_TYPE.NUMBER, desc: '取值 0/1/2/3/4/5/6/...' },
+      { key: 'firstContactDate', label: 'First contact date', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM-DD' },
+      { key: 'openDate', label: '开户时间', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM-DD' },
+      { key: 'city', label: '城市', type: PARAM_TYPE.STRING },
+      { key: 'rmType', label: 'RM 类型', type: PARAM_TYPE.STRING },
+      { key: 'customerName', label: '客户姓名', type: PARAM_TYPE.STRING }
+    ]
+  },
+  {
+    key: 'asset',
+    label: '资产与持有',
+    params: [
+      { key: 'endOfBalance', label: 'End of day balance(所有资金含产品)', type: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'avgTrbIncludingHousehold', label: 'TRB 值-Avg TRB 口径(含家庭账户余额)', type: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'avgTrbExcludingHousehold', label: 'TRB 值-Avg TRB 口径(不含家庭账户余额)', type: PARAM_TYPE.NUMBER, hasHistory: true },
+      {
+        key: 'assetCategoryDetails',
+        label: '资产大类明细',
+        type: PARAM_TYPE.STRING,
+        multiple: true,
+        options: ENUMS.assetCategoryDetails
+      },
+      {
+        key: 'fixedDepositTypes',
+        label: '客户持有定存种类',
+        type: PARAM_TYPE.STRING,
+        multiple: true,
+        options: ENUMS.fixedDepositTypes
+      }
+    ]
+  },
+  {
+    key: 'premier',
+    label: '尊尚 / 私行',
+    params: [
+      { key: 'isQualifiedLast6Months', label: '近 6 个月是否符合尊尚资格', type: PARAM_TYPE.BOOLEAN },
+      { key: 'privateBankTag', label: '私行权益标签', type: PARAM_TYPE.STRING, options: ENUMS.privateBankTag },
+      { key: 'firstUpgradePremierDate', label: '首次升级尊尚的时间', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM-DD' },
+      { key: 'privateBankId', label: '私行 ID 卓越理财尊尚编号', type: PARAM_TYPE.STRING },
+      { key: 'premierValidityDate', label: '尊尚资格到期日', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM-DD' },
+      { key: 'firstAchievementDate', label: '首次达标日期', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM' },
+      { key: 'firstPBUpgradeDate', label: '首次升级为 PB 的日期', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM' },
+      { key: 'isPbActivityWhitelisted', label: 'PB 活动参与白名单', type: PARAM_TYPE.BOOLEAN }
+    ]
+  },
+  {
+    key: 'crossBorder',
+    label: '跨境资格(1+1)',
+    params: [
+      { key: 'isCrossBorderHK', label: '是否为 1+1 CN-HK 双边客户', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isCrossBorderSG', label: '是否为 1+1 CN-SG 双边客户', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isHKQualifier', label: '是否为 HK qualifier', type: PARAM_TYPE.BOOLEAN },
+      { key: 'cnHkQualifierDate', label: 'New to 1+1 CN-HK qualifier 时间', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM' },
+      { key: 'isSGQualifier', label: '是否为 SG qualifier', type: PARAM_TYPE.BOOLEAN },
+      { key: 'cnSgQualifierDate', label: 'New to 1+1 CN-SG qualifier 时间', type: PARAM_TYPE.DATE, dateFormat: 'YYYY-MM' }
+    ]
+  },
+  {
+    key: 'mgm',
+    label: 'MGM / 推荐关系',
+    params: [
+      { key: 'monthlySuccessfulReferrals', label: '某自然月成功推荐人数', type: PARAM_TYPE.LIST, listItemType: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'yearlySuccessfulReferrals', label: '某自然年成功推荐人数', type: PARAM_TYPE.LIST, listItemType: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'isReferredAndQualified', label: '是否被成功推荐来开户且达标', type: PARAM_TYPE.BOOLEAN },
+      { key: 'mgmCaseId', label: 'MGM case ID', type: PARAM_TYPE.STRING },
+      { key: 'monthlyReferrals', label: '某自然月推荐人数', type: PARAM_TYPE.LIST, listItemType: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'yearlyReferrals', label: '某自然年推荐人数', type: PARAM_TYPE.LIST, listItemType: PARAM_TYPE.NUMBER, hasHistory: true },
+      { key: 'referralRelation', label: '推荐关系', type: PARAM_TYPE.REFERRAL, desc: '被推荐人客户号列表' }
+    ]
+  },
+  {
+    key: 'rpq',
+    label: '投资偏好',
+    params: [
+      { key: 'validRpq', label: '客户持有 valid RPQ 等级', type: PARAM_TYPE.STRING, options: ENUMS.validRpq },
+      { key: 'prospectsNeeds', label: 'Prospects Needs', type: PARAM_TYPE.BOOLEAN }
+    ]
+  },
+  {
+    key: 'behavior',
+    label: '行为与触点',
+    params: [
+      { key: 'isHsbcWechatLinked', label: '是否关注"汇丰中国客户服务"微信服务号且绑定银行账户', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isPrevMonthInactiveCurMonthActive', label: '上个自然月未登录、当前自然月登录过手机银行 1 天或以上', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isPrevTwoMonthsActive', label: '从当前自然月起前序连续 2 个完整月是否登录手机银行', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isRmWeComBound', label: '是否绑定 RM 企业微信', type: PARAM_TYPE.BOOLEAN },
+      { key: 'firstBiometricLogin', label: '是否首次使用 Face ID 或指纹登录手机银行', type: PARAM_TYPE.BOOLEAN },
+      { key: 'isSimulatedAllocationCompleted', label: '成功完成模拟资产配置的客户', type: PARAM_TYPE.BOOLEAN }
+    ]
+  }
+]
+
+// 扁平参数字典:key -> param
+export const PARAM_MAP = (() => {
+  const map = {}
+  PARAM_GROUPS.forEach((group) => {
+    group.params.forEach((p) => {
+      map[p.key] = { ...p, groupKey: group.key, groupLabel: group.label }
+    })
+  })
+  return map
+})()
+
+// 根据参数推导可用运算符
+export function getOperatorsByParam(param) {
+  if (!param) return []
+  switch (param.type) {
+    case PARAM_TYPE.STRING:
+      return param.options ? OPERATORS.STRING_ENUM : OPERATORS.STRING_TEXT
+    case PARAM_TYPE.NUMBER:
+      return OPERATORS.NUMBER
+    case PARAM_TYPE.BOOLEAN:
+      return OPERATORS.BOOLEAN
+    case PARAM_TYPE.DATE:
+      return OPERATORS.DATE
+    case PARAM_TYPE.LIST:
+      // List 先聚合再判断,聚合后以数值运算符为主
+      return OPERATORS.NUMBER
+    case PARAM_TYPE.REFERRAL:
+      // 推荐关系:1. 列表长度(数值) 2. 是否包含指定客户号
+      return [
+        { value: 'lengthEq', label: '推荐人数 =' },
+        { value: 'lengthGte', label: '推荐人数 ≥' },
+        { value: 'lengthLte', label: '推荐人数 ≤' },
+        { value: 'includesCustomerId', label: '包含客户号' },
+        { value: 'notIncludesCustomerId', label: '不包含客户号' }
+      ]
+    default:
+      return []
+  }
+}
+
+// 是否需要展示时间窗口
+export function needTimeWindow(param) {
+  return !!(param && param.hasHistory)
+}
+
+// 是否需要填写比较值
+export function needValueInput(operator) {
+  return !['isNull', 'isNotNull'].includes(operator)
+}
+
+// 布尔值下拉选项
+export const BOOLEAN_OPTIONS = [
+  { value: true, label: '真' },
+  { value: false, label: '假' }
+]
+
+// 是否是区间运算符
+export function isBetweenOperator(operator) {
+  return operator === 'between'
+}
+
+// 是否是多值运算符
+export function isMultiValueOperator(operator) {
+  return ['in', 'notIn'].includes(operator)
+}

+ 22 - 0
pc-v3/src/main.js

@@ -0,0 +1,22 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import ElementPlus from 'element-plus'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import 'element-plus/dist/index.css'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import { QuillEditor } from '@vueup/vue-quill'
+import '@vueup/vue-quill/dist/vue-quill.snow.css'
+import './styles/quill.css'
+import router from './router'
+import './mock'
+
+const app = createApp(App)
+
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+
+app.component('QuillEditor', QuillEditor)
+app.use(ElementPlus, { locale: zhCn })
+app.use(router)
+app.mount('#app')

+ 1026 - 0
pc-v3/src/mock/index.js

@@ -0,0 +1,1026 @@
+import Mock from 'mockjs'
+
+// =============================================
+// 登录
+// =============================================
+Mock.mock(/\/api\/login/, 'post', (options) => {
+  const { username, password } = JSON.parse(options.body || '{}')
+  if (!username || !password) {
+    return { code: 400, data: null, msg: '账号或密码不能为空' }
+  }
+  if (username === 'admin' && password === '123456') {
+    return {
+      code: 200,
+      data: { token: 'mock-token-' + Date.now(), username },
+      msg: '登录成功'
+    }
+  }
+  return { code: 401, data: null, msg: '账号或密码错误' }
+})
+
+// =============================================
+// 页面管理 - 权益介绍页配置
+// =============================================
+const homeConfigData = {
+  premier: {
+    tabName: '卓越理财',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '卓越理财',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  },
+  elite: {
+    tabName: '卓越理财·尊尚',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '卓越理财·尊尚',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  },
+  global: {
+    tabName: '环球私人银行',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '环球私人银行',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  }
+}
+
+let homeStatus = 'published'
+
+Mock.mock(/\/api\/page-config\/home/, 'get', () => {
+  return { code: 200, data: { config: homeConfigData, status: homeStatus }, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/page-config\/home/, 'put', (options) => {
+  const body = JSON.parse(options.body)
+  Object.assign(homeConfigData, body.config || {})
+  homeStatus = body.status || homeStatus
+  return { code: 200, data: null, msg: '保存成功' }
+})
+
+// =============================================
+// 页面管理 - 权益首页配置
+// =============================================
+const zoneConfigData = {
+  identityModule: {
+    advance: {
+      bgImage: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800',
+      identityText: '汇丰运筹理财',
+      showQualifyStatus: false,
+      showAvgBalance: true,
+      avgBalanceRule: '月内日均余额 = 当月内每日账户余额之和 / 当月天数。'
+    },
+    premier: {
+      bgImage: 'https://images.unsplash.com/photo-1583847268964-b28dc8f51f92?w=800',
+      identityText: '汇丰卓越理财',
+      showQualifyStatus: true,
+      showAvgBalance: true,
+      avgBalanceRule: '计算规则:截止到昨日的账户月内日均余额。'
+    },
+    premierElite: {
+      bgImage: 'https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=800',
+      identityText: '汇丰卓越理财·尊尚',
+      showQualifyStatus: true,
+      showAvgBalance: true,
+      avgBalanceRule: '计算规则:截止到昨日的账户月内日均余额。支持查看合格状态及复制编号。'
+    },
+    privateBank: {
+      bgImage: 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800',
+      identityText: '汇丰环球私人银行',
+      showPbLevel: true
+    }
+  },
+  adCarousel: [{
+    imageUrl: 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800',
+    linkH5: '', linkApp: '', wechatAppId: '', wechatPath: '',
+    title: '汇丰卓越理财新户达标礼',
+    subtitle: '报名参加汇丰活动,体验更多精彩礼遇',
+    tags: ['t1', 't2']
+  }],
+  exchangeConfig: {
+    items: [
+      { imageUrl: 'https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=200', title: '航空里程', link: '/flight' },
+      { imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=200', title: '贵宾厅', link: '/lounge' }
+    ]
+  },
+  ruleModal: {
+    title: '积分规则',
+    content: '<p>本积分使用规则为汇丰银行(中国)有限公司...</p>'
+  }
+}
+
+let zoneStatus = 'published'
+
+const availableTags = Array.from({ length: 8 }, (_, i) => ({ id: `t${i + 1}`, name: `人群标签${i + 1}` }))
+
+Mock.mock(/\/api\/page-config\/zone/, 'get', () => {
+  return { code: 200, data: { config: zoneConfigData, status: zoneStatus, availableTags }, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/page-config\/zone/, 'put', (options) => {
+  const body = JSON.parse(options.body)
+  Object.assign(zoneConfigData, body.config || {})
+  zoneStatus = body.status || zoneStatus
+  return { code: 200, data: null, msg: '保存成功' }
+})
+
+// =============================================
+// 页面管理 - 组装页
+// =============================================
+let assemblyIdCounter = 3000
+
+const assemblyList = [
+  {
+    id: 'A7538', name: '双十一迎新专属活动页', status: 'published',
+    createTime: '2025-10-01 10:00:00', updateTime: '2025-10-15 14:30:00',
+    blocks: [
+      { type: 'image', id: 'b_init1', data: { imageUrl: 'https://images.unsplash.com/photo-1557683316-973673baf926?w=800' } },
+      { type: 'privilege', id: 'b_init2', data: { title: '专属权益', items: [{ id: '1', title: '机场贵宾厅服务', desc: '全年免费使用6次指定机场贵宾厅' }, { id: '2', title: '境内礼宾车', desc: '免费2次接送' }] } },
+      { type: 'richtext', id: 'b_init3', data: { content: '<p>欢迎参与双十一专属福利活动...</p>' } },
+      { type: 'buttonGroup', id: 'b_init4', data: { buttons: [{ text: '立即参与', link: '/join', style: 'primary', actionType: 'link', dialog: { title: '', desc: '', confirmText: '确认', confirmLink: '' } }] } }
+    ]
+  },
+  {
+    id: 'A2091', name: '私行高净值沙龙引导页', status: 'draft',
+    createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00',
+    blocks: [
+      { type: 'image', id: 'b_init5', data: { imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800' } },
+      { type: 'privilege', id: 'b_init6', data: { title: '尊享体验', items: [{ id: '1', title: '高尔夫权益', desc: 'VIP高尔夫俱乐部免会籍预约' }] } }
+    ]
+  }
+]
+
+Mock.mock(/\/api\/assembly\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id') || ''
+  const name = url.searchParams.get('name') || ''
+  let filtered = assemblyList
+  if (id) filtered = filtered.filter(a => a.id.includes(id))
+  if (name) filtered = filtered.filter(a => a.name.includes(name))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/assembly\/detail/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id')
+  const item = assemblyList.find(a => a.id === id)
+  return item
+    ? { code: 200, data: item, msg: 'ok' }
+    : { code: 404, data: null, msg: '未找到' }
+})
+
+Mock.mock(/\/api\/assembly\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = assemblyList.findIndex(a => a.id === body.id)
+    if (idx >= 0) {
+      assemblyList[idx] = { ...assemblyList[idx], ...body, updateTime: now }
+    }
+  } else {
+    body.id = 'A' + (++assemblyIdCounter)
+    body.createTime = now
+    body.updateTime = now
+    assemblyList.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/assembly\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = assemblyList.findIndex(a => a.id === body.id)
+  if (idx >= 0) assemblyList.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// =============================================
+// 渠道管理 - Channel
+// =============================================
+const channelDict = {
+  'Human-assisted': {
+    '汇丰中国客户服务号': ['汇丰中国客户服务号'],
+    'MGM': ['MGM - Mortgage', 'MGM - Premier'],
+    'CCSS': ['CCSS SMS'],
+    'Referral': ['GPB Referral', 'HK referral', 'Non WPB staff Referral', 'Pinnacle Referral', 'RBB Refferal'],
+    'New staff': ['New staff'],
+    'IBC': ['IBC referral staff code'],
+    'Group Acct': ['HTC', 'NIVIDA']
+  },
+  'Unassisted': {
+    '汇丰中国APP': ['Mobile X'],
+    '汇丰中国官网': ['PWS site'],
+    '汇丰中国小程序': ['汇丰中国小程序'],
+    '汇丰中国订阅号': ['汇丰中国订阅号', '汇丰中国订阅号推文', '推文尾部入口'],
+    'Chatbot': ['Chatbot'],
+    'SMS': ['SMS'],
+    'Paid Media': ['汇丰中国订阅号推文', 'others'],
+    'Others': ['others', 'River APP']
+  }
+}
+
+let channelIdCounter = 9
+const channelRows = [
+  { id: 'CH0001', type: 'Human-assisted', category: '汇丰中国客户服务号', name: '汇丰中国客户服务号', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0002', type: 'Human-assisted', category: 'MGM', name: 'MGM - Mortgage', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0003', type: 'Human-assisted', category: 'MGM', name: 'MGM - Premier', subName: '双十一特惠', owner: '张经理', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-21 14:30:00' },
+  { id: 'CH0004', type: 'Human-assisted', category: 'CCSS', name: 'CCSS SMS', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0005', type: 'Human-assisted', category: 'Referral', name: 'GPB Referral', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0006', type: 'Unassisted', category: '汇丰中国APP', name: 'Mobile X', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0007', type: 'Unassisted', category: '汇丰中国官网', name: 'PWS site', subName: '私行沙龙引导', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0008', type: 'Unassisted', category: '汇丰中国订阅号', name: '汇丰中国订阅号', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0009', type: 'Unassisted', category: '汇丰中国订阅号', name: '推文尾部入口', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' }
+]
+
+Mock.mock(/\/api\/channel\/dict/, 'get', () => {
+  return { code: 200, data: channelDict, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/channel\/dict\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const { target, type, category, oldValue, newValue, mode } = body
+  if (target === 'type') {
+    if (mode === 'add' && channelDict[newValue]) return { code: 400, data: null, msg: 'Channel 类型已存在,不能重复新增' }
+    if (mode === 'edit' && oldValue !== newValue && channelDict[newValue]) return { code: 400, data: null, msg: 'Channel 类型已存在,不能修改为重复值' }
+    if (mode === 'add') { channelDict[newValue] = {} }
+    else { channelDict[newValue] = channelDict[oldValue]; delete channelDict[oldValue]; channelRows.forEach(r => { if (r.type === oldValue) r.type = newValue }) }
+  } else if (target === 'category') {
+    if (mode === 'add' && channelDict[type][newValue]) return { code: 400, data: null, msg: 'Channel 类别已存在,不能重复新增' }
+    if (mode === 'edit' && oldValue !== newValue && channelDict[type][newValue]) return { code: 400, data: null, msg: 'Channel 类别已存在,不能修改为重复值' }
+    if (mode === 'add') { channelDict[type][newValue] = [] }
+    else { channelDict[type][newValue] = channelDict[type][oldValue]; delete channelDict[type][oldValue]; channelRows.forEach(r => { if (r.type === type && r.category === oldValue) r.category = newValue }) }
+  } else if (target === 'name') {
+    const list = channelDict[type][category]
+    if (mode === 'add' && list.includes(newValue)) return { code: 400, data: null, msg: 'Channel 名称已存在,不能重复新增' }
+    if (mode === 'edit' && oldValue !== newValue && list.includes(newValue)) return { code: 400, data: null, msg: 'Channel 名称已存在,不能修改为重复值' }
+    if (mode === 'add') { list.push(newValue) }
+    else { const idx = list.indexOf(oldValue); if (idx >= 0) list[idx] = newValue; channelRows.forEach(r => { if (r.type === type && r.category === category && r.name === oldValue) r.name = newValue }) }
+  }
+  return { code: 200, data: channelDict, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/channel\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const sId = (url.searchParams.get('id') || '').toLowerCase()
+  const sType = url.searchParams.get('type') || ''
+  const sCategory = (url.searchParams.get('category') || '').toLowerCase()
+  const sName = (url.searchParams.get('name') || '').toLowerCase()
+  const sSubName = (url.searchParams.get('subName') || '').toLowerCase()
+  const sOwner = (url.searchParams.get('owner') || '').toLowerCase()
+  let filtered = channelRows
+  if (sId) filtered = filtered.filter(c => c.id.toLowerCase().includes(sId))
+  if (sType) filtered = filtered.filter(c => c.type === sType)
+  if (sCategory) filtered = filtered.filter(c => c.category.toLowerCase().includes(sCategory))
+  if (sName) filtered = filtered.filter(c => c.name.toLowerCase().includes(sName))
+  if (sSubName) filtered = filtered.filter(c => c.subName.toLowerCase().includes(sSubName))
+  if (sOwner) filtered = filtered.filter(c => c.owner.toLowerCase().includes(sOwner))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/channel\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = channelRows.findIndex(c => c.id === body.id)
+    if (idx >= 0) channelRows[idx] = { ...channelRows[idx], ...body, updateTime: now }
+  } else {
+    body.id = 'CH' + String(++channelIdCounter).padStart(4, '0')
+    body.createTime = now
+    body.updateTime = now
+    channelRows.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/channel\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = channelRows.findIndex(c => c.id === body.id)
+  if (idx >= 0) channelRows.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// =============================================
+// 渠道管理 - Campaign
+// =============================================
+let campaignIdCounter = 9002
+const campaignRows = [
+  { id: 'CP9001', name: 'Q3 大客户回馈', owner: 'Market Team', staff: '王专员', createTime: '2025-10-01 10:00:00', updateTime: '2025-10-15 14:30:00' },
+  { id: 'CP9002', name: '新春开户活动', owner: 'Retail Team', staff: '李专员', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' }
+]
+
+Mock.mock(/\/api\/campaign\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const sId = (url.searchParams.get('id') || '').toLowerCase()
+  const sName = (url.searchParams.get('name') || '').toLowerCase()
+  const sOwner = (url.searchParams.get('owner') || '').toLowerCase()
+  const sStaff = (url.searchParams.get('staff') || '').toLowerCase()
+  let filtered = campaignRows
+  if (sId) filtered = filtered.filter(c => c.id.toLowerCase().includes(sId))
+  if (sName) filtered = filtered.filter(c => c.name.toLowerCase().includes(sName))
+  if (sOwner) filtered = filtered.filter(c => c.owner.toLowerCase().includes(sOwner))
+  if (sStaff) filtered = filtered.filter(c => c.staff.toLowerCase().includes(sStaff))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/campaign\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = campaignRows.findIndex(c => c.id === body.id)
+    if (idx >= 0) campaignRows[idx] = { ...campaignRows[idx], ...body, updateTime: now }
+  } else {
+    body.id = 'CP' + (++campaignIdCounter)
+    body.createTime = now
+    body.updateTime = now
+    campaignRows.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/campaign\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = campaignRows.findIndex(c => c.id === body.id)
+  if (idx >= 0) campaignRows.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// =============================================
+// 借记卡权益 + 审核中心
+// =============================================
+
+// 商品状态:未上架 / 已上架
+// 审核状态:草稿 / 上架待审核 / 下架待审核 / 审核通过
+// 合法组合(5 种):
+//   未上架+草稿        = 草稿
+//   未上架+上架待审核   = 新增待审核
+//   未上架+审核通过     = 已下架
+//   已上架+审核通过     = 已上架
+//   已上架+下架待审核   = 已上架·下架待审核
+const equityList = [
+  {
+    productId: 'Q100001',
+    equityName: '权益A',
+    source: '银行自有',
+    jumpMethod: '-',
+    jumpLink: '-',
+    exchangeMethod: '权益平台',
+    productType: '券码',
+    skuNo: 'SKU001',
+    merchantName: '商户A',
+    creator: '李运营',
+    createTime: '2026-04-10 09:32:15',
+    reviewer: '王五',
+    reviewTime: '2026-04-15 10:30:00',
+    shelfStatus: '已上架',
+    auditStatus: '审核通过',
+    showOnHome: '显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  },
+  {
+    productId: 'Q100002',
+    equityName: '权益B',
+    source: '第三方',
+    jumpMethod: '没有',
+    jumpLink: '太古地产',
+    exchangeMethod: '第三方(列表跳转)',
+    productType: '',
+    skuNo: 'SKU002',
+    merchantName: '商户B',
+    creator: '王运营',
+    createTime: '2026-04-12 14:08:42',
+    reviewer: '',
+    reviewTime: '',
+    shelfStatus: '未上架',
+    auditStatus: '上架待审核',
+    showOnHome: '显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  },
+  {
+    productId: 'Q100003',
+    equityName: '权益C',
+    source: '第三方',
+    jumpMethod: '有',
+    jumpLink: '龙腾',
+    exchangeMethod: '第三方(详情页跳转)',
+    productType: '',
+    skuNo: 'SKU003',
+    merchantName: '商户C',
+    creator: '李运营',
+    createTime: '2026-04-08 11:20:05',
+    reviewer: '王五',
+    reviewTime: '2026-04-14 16:45:00',
+    shelfStatus: '已上架',
+    auditStatus: '审核通过',
+    showOnHome: '显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  },
+  {
+    productId: 'Q100004',
+    equityName: '权益D',
+    source: '第三方',
+    jumpMethod: '有',
+    jumpLink: 'h5链接',
+    exchangeMethod: '第三方(详情页跳转)',
+    productType: '',
+    skuNo: 'SKU004',
+    merchantName: '商户D',
+    creator: '陈运营',
+    createTime: '2026-04-05 15:50:18',
+    reviewer: '赵六',
+    reviewTime: '2026-04-13 09:15:00',
+    shelfStatus: '已上架',
+    auditStatus: '下架待审核',
+    showOnHome: '显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  },
+  {
+    productId: 'Q100005',
+    equityName: '权益E',
+    source: '银行自有',
+    jumpMethod: '-',
+    jumpLink: '-',
+    exchangeMethod: '权益平台',
+    productType: '券码',
+    skuNo: 'SKU005',
+    merchantName: '商户E',
+    creator: '王运营',
+    createTime: '2026-03-28 09:00:11',
+    reviewer: '钱七',
+    reviewTime: '2026-04-12 11:20:00',
+    shelfStatus: '未上架',
+    auditStatus: '审核通过',
+    showOnHome: '显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  },
+  {
+    productId: 'Q100006',
+    equityName: '权益F(草稿)',
+    source: '银行自有',
+    jumpMethod: '-',
+    jumpLink: '-',
+    exchangeMethod: '权益平台',
+    productType: '券码',
+    skuNo: 'SKU006',
+    merchantName: '商户F',
+    creator: '李运营',
+    createTime: '2026-05-16 10:00:00',
+    reviewer: '',
+    reviewTime: '',
+    shelfStatus: '未上架',
+    auditStatus: '草稿',
+    showOnHome: '不显示',
+    showOnAssembly: '不显示',
+    assemblyPages: []
+  }
+]
+
+let equityIdCounter = 100006
+
+const auditList = [
+  {
+    id: 'AR202605150001',
+    auditType: 'new',
+    bizModule: 'equity_product',
+    bizModuleName: '借记卡权益',
+    bizId: 'Q100002',
+    bizName: '权益B',
+    snapshot: {
+      equityName: '权益B',
+      source: '第三方',
+      jumpMethod: '没有',
+      jumpLink: '太古地产',
+      productType: '',
+      skuNo: 'SKU002',
+      merchantName: '商户B',
+      subTitle: '外部列表页副标题'
+    },
+    submitReason: '新建第三方权益,链接已对齐',
+    submitterId: 'u001',
+    submitterName: '王运营',
+    submitTime: '2026-05-15 10:00:00',
+    auditorId: null,
+    auditorName: '',
+    auditTime: '',
+    auditStatus: 'pending',
+    auditComment: ''
+  },
+  {
+    id: 'AR202605170001',
+    auditType: 'offshelf',
+    bizModule: 'equity_product',
+    bizModuleName: '借记卡权益',
+    bizId: 'Q100004',
+    bizName: '权益D',
+    snapshot: {
+      equityName: '权益D',
+      source: '第三方',
+      jumpMethod: '有',
+      jumpLink: 'h5链接',
+      productType: '',
+      skuNo: 'SKU004',
+      merchantName: '商户D',
+      subTitle: '外部自定义链接副标题'
+    },
+    submitReason: '业务合作到期,申请下架',
+    submitterId: 'u003',
+    submitterName: '陈运营',
+    submitTime: '2026-05-17 09:00:00',
+    auditorId: null,
+    auditorName: '',
+    auditTime: '',
+    auditStatus: 'pending',
+    auditComment: ''
+  },
+  {
+    id: 'AR202605120001',
+    auditType: 'new',
+    bizModule: 'equity_product',
+    bizModuleName: '借记卡权益',
+    bizId: 'Q100001',
+    bizName: '权益A',
+    snapshot: {
+      equityName: '权益A',
+      source: '银行自有',
+      jumpMethod: '-',
+      jumpLink: '-',
+      productType: '券码',
+      skuNo: 'SKU001',
+      merchantName: '商户A',
+      subTitle: '副标题示例'
+    },
+    submitReason: '',
+    submitterId: 'u001',
+    submitterName: '李运营',
+    submitTime: '2026-04-10 09:32:15',
+    auditorId: 'u099',
+    auditorName: '王五',
+    auditTime: '2026-04-15 10:30:00',
+    auditStatus: 'approved',
+    auditComment: '内容合规,准予上架'
+  }
+]
+
+let auditIdCounter = 4
+
+function nowStr() {
+  return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')
+}
+
+function nextAuditId() {
+  auditIdCounter += 1
+  const d = new Date()
+  const ymd = d.getFullYear().toString() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0')
+  return 'AR' + ymd + String(auditIdCounter).padStart(4, '0')
+}
+
+function snapshotOf(equity) {
+  return {
+    equityName: equity.equityName,
+    source: equity.source,
+    jumpMethod: equity.jumpMethod,
+    jumpLink: equity.jumpLink,
+    productType: equity.productType,
+    skuNo: equity.skuNo,
+    merchantName: equity.merchantName,
+    subTitle: equity.subTitle || ''
+  }
+}
+
+// 借记卡权益 - 列表
+Mock.mock(/\/api\/equity\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const equityName = url.searchParams.get('equityName') || ''
+  const productId = url.searchParams.get('productId') || ''
+  const shelfStatus = url.searchParams.get('shelfStatus') || ''
+  const auditStatus = url.searchParams.get('auditStatus') || ''
+  let filtered = equityList
+  if (equityName) filtered = filtered.filter(e => e.equityName.includes(equityName))
+  if (productId) filtered = filtered.filter(e => e.productId.includes(productId))
+  if (shelfStatus) filtered = filtered.filter(e => e.shelfStatus === shelfStatus)
+  if (auditStatus) filtered = filtered.filter(e => e.auditStatus === auditStatus)
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+// 借记卡权益 - 详情
+Mock.mock(/\/api\/equity\/detail/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id')
+  const item = equityList.find(e => e.productId === id)
+  return item
+    ? { code: 200, data: item, msg: 'ok' }
+    : { code: 404, data: null, msg: '权益不存在' }
+})
+
+// 保存草稿(新建或修改草稿)
+Mock.mock(/\/api\/equity\/save-draft/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  if (body.productId) {
+    // 修改草稿
+    const idx = equityList.findIndex(e => e.productId === body.productId)
+    if (idx < 0) return { code: 404, data: null, msg: '权益不存在' }
+    const cur = equityList[idx]
+    if (cur.auditStatus !== '草稿') {
+      return { code: 400, data: null, msg: '仅草稿状态可保存为草稿' }
+    }
+    equityList[idx] = { ...cur, ...body, shelfStatus: '未上架', auditStatus: '草稿' }
+    return { code: 200, data: { productId: cur.productId }, msg: '草稿已保存' }
+  }
+  // 新建草稿
+  equityIdCounter += 1
+  const newId = 'Q' + String(equityIdCounter)
+  const newRow = {
+    productId: newId,
+    equityName: body.equityName || '未命名权益',
+    source: body.source || '银行自有',
+    jumpMethod: body.jumpMethod || '-',
+    jumpLink: body.jumpLink || '-',
+    exchangeMethod: body.exchangeMethod || '权益平台',
+    productType: body.productType || '',
+    skuNo: body.skuNo || '',
+    merchantName: body.merchantName || '',
+    creator: body.creator || '李运营',
+    createTime: nowStr(),
+    reviewer: '',
+    reviewTime: '',
+    shelfStatus: '未上架',
+    auditStatus: '草稿',
+    showOnHome: body.showOnHome || '不显示',
+    showOnAssembly: body.showOnAssembly || '不显示',
+    assemblyPages: body.assemblyPages || [],
+    subTitle: body.subTitle || ''
+  }
+  equityList.unshift(newRow)
+  return { code: 200, data: { productId: newId }, msg: '草稿已保存' }
+})
+
+// 提交审核(新增上架审核 / 已上架编辑提审 / 已下架重新提审)
+Mock.mock(/\/api\/equity\/submit/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  let cur
+  let idx = -1
+  if (body.productId) {
+    idx = equityList.findIndex(e => e.productId === body.productId)
+    if (idx < 0) return { code: 404, data: null, msg: '权益不存在' }
+    cur = equityList[idx]
+  }
+
+  const submitReason = body.submitReason || ''
+  const submitter = body.submitterName || '李运营'
+
+  // 新建 + 直接提审
+  if (!cur) {
+    equityIdCounter += 1
+    const newId = 'Q' + String(equityIdCounter)
+    const newRow = {
+      productId: newId,
+      equityName: body.equityName || '未命名权益',
+      source: body.source || '银行自有',
+      jumpMethod: body.jumpMethod || '-',
+      jumpLink: body.jumpLink || '-',
+      exchangeMethod: body.exchangeMethod || '权益平台',
+      productType: body.productType || '',
+      skuNo: body.skuNo || '',
+      merchantName: body.merchantName || '',
+      creator: submitter,
+      createTime: nowStr(),
+      reviewer: '',
+      reviewTime: '',
+      shelfStatus: '未上架',
+      auditStatus: '上架待审核',
+      showOnHome: body.showOnHome || '不显示',
+      showOnAssembly: body.showOnAssembly || '不显示',
+      assemblyPages: body.assemblyPages || [],
+      subTitle: body.subTitle || ''
+    }
+    equityList.unshift(newRow)
+    auditList.unshift({
+      id: nextAuditId(),
+      auditType: 'new',
+      bizModule: 'equity_product',
+      bizModuleName: '借记卡权益',
+      bizId: newId,
+      bizName: newRow.equityName,
+      snapshot: snapshotOf(newRow),
+      submitReason,
+      submitterId: 'u001',
+      submitterName: submitter,
+      submitTime: nowStr(),
+      auditorId: null,
+      auditorName: '',
+      auditTime: '',
+      auditStatus: 'pending',
+      auditComment: ''
+    })
+    return { code: 200, data: { productId: newId }, msg: '已提交审核' }
+  }
+
+  // 草稿 → 上架待审核(new)
+  if (cur.shelfStatus === '未上架' && cur.auditStatus === '草稿') {
+    const merged = { ...cur, ...body }
+    equityList[idx] = { ...merged, shelfStatus: '未上架', auditStatus: '上架待审核' }
+    auditList.unshift({
+      id: nextAuditId(),
+      auditType: 'new',
+      bizModule: 'equity_product',
+      bizModuleName: '借记卡权益',
+      bizId: cur.productId,
+      bizName: equityList[idx].equityName,
+      snapshot: snapshotOf(equityList[idx]),
+      submitReason,
+      submitterId: 'u001',
+      submitterName: submitter,
+      submitTime: nowStr(),
+      auditorId: null,
+      auditorName: '',
+      auditTime: '',
+      auditStatus: 'pending',
+      auditComment: ''
+    })
+    return { code: 200, data: { productId: cur.productId }, msg: '已提交审核' }
+  }
+
+  // 已下架 → 重新上架(也是 new)
+  if (cur.shelfStatus === '未上架' && cur.auditStatus === '审核通过') {
+    equityList[idx] = { ...cur, ...body, shelfStatus: '未上架', auditStatus: '上架待审核' }
+    auditList.unshift({
+      id: nextAuditId(),
+      auditType: 'new',
+      bizModule: 'equity_product',
+      bizModuleName: '借记卡权益',
+      bizId: cur.productId,
+      bizName: equityList[idx].equityName,
+      snapshot: snapshotOf(equityList[idx]),
+      submitReason,
+      submitterId: 'u001',
+      submitterName: submitter,
+      submitTime: nowStr(),
+      auditorId: null,
+      auditorName: '',
+      auditTime: '',
+      auditStatus: 'pending',
+      auditComment: ''
+    })
+    return { code: 200, data: { productId: cur.productId }, msg: '已提交审核' }
+  }
+
+  // 已上架商品禁止直接编辑(需先走下架审核)
+
+  return { code: 400, data: null, msg: '当前状态不可提交审核' }
+})
+
+// 申请下架
+Mock.mock(/\/api\/equity\/request-offshelf/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = equityList.findIndex(e => e.productId === body.productId)
+  if (idx < 0) return { code: 404, data: null, msg: '权益不存在' }
+  const cur = equityList[idx]
+  if (cur.shelfStatus !== '已上架' || cur.auditStatus !== '审核通过') {
+    return { code: 400, data: null, msg: '仅已上架且无审核中的权益可申请下架' }
+  }
+  equityList[idx] = { ...cur, auditStatus: '下架待审核' }
+  auditList.unshift({
+    id: nextAuditId(),
+    auditType: 'offshelf',
+    bizModule: 'equity_product',
+    bizModuleName: '借记卡权益',
+    bizId: cur.productId,
+    bizName: cur.equityName,
+    snapshot: snapshotOf(cur),
+    submitReason: '',
+    submitterId: 'u001',
+    submitterName: body.submitterName || '李运营',
+    submitTime: nowStr(),
+    auditorId: null,
+    auditorName: '',
+    auditTime: '',
+    auditStatus: 'pending',
+    auditComment: ''
+  })
+  return { code: 200, data: null, msg: '已提交下架审核' }
+})
+
+// 删除权益(仅草稿/已下架)
+Mock.mock(/\/api\/equity\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = equityList.findIndex(e => e.productId === body.productId)
+  if (idx < 0) return { code: 404, data: null, msg: '权益不存在' }
+  const cur = equityList[idx]
+  const canDelete = (cur.shelfStatus === '未上架' && (cur.auditStatus === '草稿' || cur.auditStatus === '审核通过'))
+  if (!canDelete) return { code: 400, data: null, msg: '当前状态不可删除' }
+  equityList.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// 审核中心 - 列表
+Mock.mock(/\/api\/audit\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const auditStatus = url.searchParams.get('auditStatus') || ''
+  const bizId = url.searchParams.get('bizId') || ''
+  let filtered = auditList
+  if (auditStatus) filtered = filtered.filter(a => a.auditStatus === auditStatus)
+  if (bizId) filtered = filtered.filter(a => String(a.bizId).includes(bizId))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+// 审核中心 - 详情
+Mock.mock(/\/api\/audit\/detail/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id')
+  const item = auditList.find(a => a.id === id)
+  return item
+    ? { code: 200, data: item, msg: 'ok' }
+    : { code: 404, data: null, msg: '审核单不存在' }
+})
+
+// 审核中心 - 通过
+Mock.mock(/\/api\/audit\/approve/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = auditList.findIndex(a => a.id === body.id)
+  if (idx < 0) return { code: 404, data: null, msg: '审核单不存在' }
+  const audit = auditList[idx]
+  if (audit.auditStatus !== 'pending') return { code: 400, data: null, msg: '该审核单已完成' }
+
+  const eqIdx = equityList.findIndex(e => e.productId === audit.bizId)
+  if (eqIdx < 0) return { code: 404, data: null, msg: '关联权益不存在' }
+  const eq = equityList[eqIdx]
+  const now = nowStr()
+  const auditor = body.auditorName || '王五'
+
+  if (audit.auditType === 'new') {
+    equityList[eqIdx] = { ...eq, shelfStatus: '已上架', auditStatus: '审核通过', reviewer: auditor, reviewTime: now }
+  } else if (audit.auditType === 'offshelf') {
+    equityList[eqIdx] = { ...eq, shelfStatus: '未上架', auditStatus: '审核通过', reviewer: auditor, reviewTime: now }
+  }
+
+  auditList[idx] = { ...audit, auditStatus: 'approved', auditorId: 'u099', auditorName: auditor, auditTime: now, auditComment: body.comment || '' }
+  return { code: 200, data: null, msg: '审核通过' }
+})
+
+// 审核中心 - 驳回
+Mock.mock(/\/api\/audit\/reject/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = auditList.findIndex(a => a.id === body.id)
+  if (idx < 0) return { code: 404, data: null, msg: '审核单不存在' }
+  const audit = auditList[idx]
+  if (audit.auditStatus !== 'pending') return { code: 400, data: null, msg: '该审核单已完成' }
+
+  const eqIdx = equityList.findIndex(e => e.productId === audit.bizId)
+  if (eqIdx < 0) return { code: 404, data: null, msg: '关联权益不存在' }
+  const eq = equityList[eqIdx]
+  const now = nowStr()
+  const auditor = body.auditorName || '王五'
+
+  // 驳回后权益主表回退到提审前的状态
+  if (audit.auditType === 'new') {
+    equityList[eqIdx] = { ...eq, shelfStatus: '未上架', auditStatus: '草稿' }
+  } else if (audit.auditType === 'offshelf') {
+    equityList[eqIdx] = { ...eq, shelfStatus: '已上架', auditStatus: '审核通过' }
+  }
+
+  auditList[idx] = { ...audit, auditStatus: 'rejected', auditorId: 'u099', auditorName: auditor, auditTime: now, auditComment: body.comment || '' }
+  return { code: 200, data: null, msg: '已驳回' }
+})
+
+// =============================================
+// 发放计划
+// =============================================
+const equityGrantCampaignList = [
+  {
+    id: 'P000002',
+    name: '夏日畅享权益月',
+    startTime: '2026-05-01 00:00:00',
+    endTime: '2026-08-31 23:59:59',
+    crowdList: [],
+    grantMode: 'claim',
+    equityList: ['Q100001', 'Q100004', 'Q100005'],
+    grantCycle: 'total',
+    grantQuantity: 1,
+    validity: { type: 'relative', fixedRange: [], relativeDays: 30 },
+    createTime: '2026-04-20 14:10:00',
+    updateTime: '2026-04-20 14:10:00'
+  },
+  {
+    id: 'P000001',
+    name: '春季会员尊享礼遇',
+    startTime: '2026-04-01 00:00:00',
+    endTime: '2026-06-30 23:59:59',
+    crowdList: ['A0000120251224'],
+    grantMode: 'direct',
+    equityList: ['Q100001'],
+    grantCycle: 'monthly',
+    grantQuantity: 2,
+    validity: { type: 'fixed', fixedRange: ['2026-04-01', '2026-06-30'], relativeDays: 30 },
+    createTime: '2026-03-25 10:20:30',
+    updateTime: '2026-03-25 10:20:30'
+  }
+]
+let equityGrantCampaignCounter = equityGrantCampaignList.length
+
+function nextGrantCampaignId() {
+  equityGrantCampaignCounter += 1
+  return 'P' + String(equityGrantCampaignCounter).padStart(6, '0')
+}
+
+Mock.mock(/\/api\/equity-grant-campaign\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const name = url.searchParams.get('name') || ''
+  const startDate = url.searchParams.get('startDate') || ''
+  const endDate = url.searchParams.get('endDate') || ''
+  let filtered = equityGrantCampaignList
+  if (name) filtered = filtered.filter(c => c.name.includes(name))
+  if (startDate) filtered = filtered.filter(c => c.endTime.slice(0, 10) >= startDate)
+  if (endDate) filtered = filtered.filter(c => c.startTime.slice(0, 10) <= endDate)
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/equity-grant-campaign\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = nowStr()
+  const defaultValidity = { type: 'fixed', fixedRange: [], relativeDays: 30 }
+  if (body.id) {
+    const idx = equityGrantCampaignList.findIndex(c => c.id === body.id)
+    if (idx < 0) return { code: 404, data: null, msg: '发放计划不存在' }
+    equityGrantCampaignList[idx] = {
+      ...equityGrantCampaignList[idx],
+      name: body.name,
+      startTime: body.startTime,
+      endTime: body.endTime,
+      crowdList: body.crowdList || [],
+      equityList: body.equityList || [],
+      grantCycle: body.grantCycle || 'total',
+      grantQuantity: body.grantQuantity || 1,
+      validity: body.validity || defaultValidity,
+      updateTime: now
+    }
+    return { code: 200, data: { id: body.id }, msg: '保存成功' }
+  }
+  const newId = nextGrantCampaignId()
+  equityGrantCampaignList.unshift({
+    id: newId,
+    name: body.name,
+    startTime: body.startTime,
+    endTime: body.endTime,
+    crowdList: body.crowdList || [],
+    equityList: body.equityList || [],
+    grantCycle: body.grantCycle || 'total',
+    grantQuantity: body.grantQuantity || 1,
+    validity: body.validity || defaultValidity,
+    createTime: now,
+    updateTime: now
+  })
+  return { code: 200, data: { id: newId }, msg: '新建成功' }
+})
+
+Mock.mock(/\/api\/equity-grant-campaign\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = equityGrantCampaignList.findIndex(c => c.id === body.id)
+  if (idx < 0) return { code: 404, data: null, msg: '发放计划不存在' }
+  equityGrantCampaignList.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+Mock.mock(/\/api\/equity-grant-campaign\/detail/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id')
+  const item = equityGrantCampaignList.find(c => c.id === id)
+  return item
+    ? { code: 200, data: item, msg: 'ok' }
+    : { code: 404, data: null, msg: '发放计划不存在' }
+})
+
+Mock.mock(/\/api\/equity-grant-campaign\/end/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = equityGrantCampaignList.findIndex(c => c.id === body.id)
+  if (idx < 0) return { code: 404, data: null, msg: '发放计划不存在' }
+  const now = nowStr()
+  equityGrantCampaignList[idx] = {
+    ...equityGrantCampaignList[idx],
+    endTime: now,
+    updateTime: now
+  }
+  return { code: 200, data: null, msg: '已结束' }
+})
+
+export default Mock

+ 271 - 0
pc-v3/src/router/index.js

@@ -0,0 +1,271 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import Layout from '@/components/Layout.vue'
+
+const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/Login.vue'),
+    meta: { public: true }
+  },
+  {
+    path: '/',
+    component: Layout,
+    redirect: '/customization/hsbc/equity-products',
+    children: [
+      {
+        path: 'customization/hsbc/pages',
+        redirect: 'customization/hsbc/pages/home',
+        meta: { title: '页面管理' }
+      },
+      {
+        path: 'customization/hsbc/pages/home',
+        name: 'PageHomeConfig',
+        component: () => import('@/views/PageHomeConfig.vue'),
+        meta: { title: '权益介绍页' }
+      },
+      {
+        path: 'customization/hsbc/pages/zone',
+        name: 'PageZoneConfig',
+        component: () => import('@/views/PageZoneConfig.vue'),
+        meta: { title: '权益首页' }
+      },
+      {
+        path: 'customization/hsbc/pages/assembly',
+        name: 'PageAssembly',
+        component: () => import('@/views/PageAssembly.vue'),
+        meta: { title: '组装页' }
+      },
+      {
+        path: 'customization/hsbc/equity-products',
+        name: 'EquityProducts',
+        component: () => import('@/views/EquityProducts.vue'),
+        meta: { title: '借记卡权益' }
+      },
+      {
+        path: 'customization/hsbc/credit-card-equity',
+        name: 'CreditCardEquity',
+        component: () => import('@/views/CreditCardEquity.vue'),
+        meta: { title: '信用卡权益' }
+      },
+      {
+        path: 'customization/hsbc/crowd-tasks',
+        name: 'CrowdTaskList',
+        component: () => import('@/views/CrowdTaskList.vue'),
+        meta: { title: '客群任务管理' }
+      },
+      {
+        path: 'customization/hsbc/crowd-tasks/add',
+        name: 'CrowdTaskAdd',
+        component: () => import('@/views/CrowdTaskEdit.vue'),
+        meta: { title: '新建客群任务' }
+      },
+      {
+        path: 'customization/hsbc/crowd-tasks/edit/:id',
+        name: 'CrowdTaskEdit',
+        component: () => import('@/views/CrowdTaskEdit.vue'),
+        meta: { title: '编辑客群任务' }
+      },
+      {
+        path: 'customization/hsbc/crowd-tasks/copy/:id',
+        name: 'CrowdTaskCopy',
+        component: () => import('@/views/CrowdTaskEdit.vue'),
+        meta: { title: '复制客群任务' }
+      },
+      {
+        path: 'customization/hsbc/crowd-tasks/view/:id',
+        name: 'CrowdTaskView',
+        component: () => import('@/views/CrowdTaskView.vue'),
+        meta: { title: '客群任务详情' }
+      },
+      {
+        path: 'customization/hsbc/crowd-name-lists/add',
+        name: 'CrowdNameListAdd',
+        component: () => import('@/views/CrowdNameListEdit.vue'),
+        meta: { title: '新增指定客户号客群' }
+      },
+      {
+        path: 'customization/hsbc/crowd-name-lists/edit/:id',
+        name: 'CrowdNameListEdit',
+        component: () => import('@/views/CrowdNameListEdit.vue'),
+        meta: { title: '编辑指定客户号客群' }
+      },
+      {
+        path: 'customization/hsbc/rule-groups',
+        name: 'RuleGroupList',
+        component: () => import('@/views/RuleGroupList.vue'),
+        meta: { title: '规则组管理' }
+      },
+      {
+        path: 'customization/hsbc/rule-groups/add',
+        name: 'RuleGroupAdd',
+        component: () => import('@/views/RuleGroupEdit.vue'),
+        meta: { title: '新增规则组' }
+      },
+      {
+        path: 'customization/hsbc/rule-groups/edit/:id',
+        name: 'RuleGroupEdit',
+        component: () => import('@/views/RuleGroupEdit.vue'),
+        meta: { title: '编辑规则组' }
+      },
+      {
+        path: 'customization/hsbc/rule-groups/view/:id',
+        name: 'RuleGroupView',
+        component: () => import('@/views/RuleGroupView.vue'),
+        meta: { title: '规则组详情' }
+      },
+      {
+        path: 'customization/hsbc/equity-products/add',
+        name: 'AddEquityProduct',
+        component: () => import('@/views/AddEquityProduct.vue'),
+        meta: { title: '添加权益' }
+      },
+      {
+        path: 'customization/hsbc/equity-products/view/:id',
+        name: 'ViewEquityProduct',
+        component: () => import('@/views/ViewEquityProduct.vue'),
+        meta: { title: '查看权益' }
+      },
+      {
+        path: 'customization/hsbc/equity-products/edit/:id',
+        name: 'EditEquityProduct',
+        component: () => import('@/views/EditEquityProduct.vue'),
+        meta: { title: '编辑权益' }
+      },
+      {
+        path: 'customization/hsbc/equity-products/audit/:id',
+        name: 'AuditEquityProduct',
+        component: () => import('@/views/AuditEquityProduct.vue'),
+        meta: { title: '审核权益' }
+      },
+      {
+        path: 'customization/hsbc/equity-products/coupons/:id',
+        name: 'EquityProductCoupons',
+        component: () => import('@/views/EquityProductCoupons.vue'),
+        meta: { title: '券码管理' }
+      },
+      {
+        path: 'customization/hsbc/tags',
+        name: 'TagList',
+        component: () => import('@/views/TagList.vue'),
+        meta: { title: '标签管理' }
+      },
+      {
+        path: 'customization/hsbc/tags/add',
+        name: 'TagAdd',
+        component: () => import('@/views/TagEdit.vue'),
+        meta: { title: '新建标签' }
+      },
+      {
+        path: 'customization/hsbc/tags/edit/:id',
+        name: 'TagEdit',
+        component: () => import('@/views/TagEdit.vue'),
+        meta: { title: '编辑标签' }
+      },
+      {
+        path: 'customization/hsbc/tags/view/:id',
+        name: 'TagView',
+        component: () => import('@/views/TagView.vue'),
+        meta: { title: '查看标签' }
+      },
+      {
+        path: 'customization/hsbc/sms-templates',
+        name: 'SmsTemplateList',
+        component: () => import('@/views/SmsTemplateList.vue'),
+        meta: { title: '短信模板管理' }
+      },
+      {
+        path: 'customization/hsbc/sms-send-records',
+        name: 'SmsSendRecord',
+        component: () => import('@/views/SmsSendRecord.vue'),
+        meta: { title: '发送记录查询' }
+      },
+      {
+        path: 'customization/hsbc/equity-grant',
+        name: 'CustomerEquityGrant',
+        component: () => import('@/views/CustomerEquityGrant.vue'),
+        meta: { title: '客户权益发放' }
+      },
+      {
+        path: 'customization/hsbc/cash-discount-grant',
+        name: 'CashDiscountGrant',
+        component: () => import('@/views/CashDiscountGrant.vue'),
+        meta: { title: '立减金发放明细' }
+      },
+      {
+        path: 'customization/hsbc/points-detail',
+        name: 'PointsDetail',
+        component: () => import('@/views/PointsDetail.vue'),
+        meta: { title: '积分明细查询' }
+      },
+      {
+        path: 'customization/hsbc/channel',
+        name: 'ChannelManagement',
+        component: () => import('@/views/ChannelManagement.vue'),
+        meta: { title: 'Channel管理' }
+      },
+      {
+        path: 'customization/hsbc/campaign',
+        name: 'CampaignManagement',
+        component: () => import('@/views/CampaignManagement.vue'),
+        meta: { title: 'Campaign管理' }
+      },
+      {
+        path: 'customization/hsbc/audit',
+        name: 'AuditList',
+        component: () => import('@/views/AuditList.vue'),
+        meta: { title: '权益审核' }
+      },
+      {
+        path: 'customization/hsbc/audit/:id',
+        name: 'AuditDetail',
+        component: () => import('@/views/AuditDetail.vue'),
+        meta: { title: '审核详情' }
+      },
+      {
+        path: 'customization/hsbc/equity-grant-campaign',
+        name: 'EquityGrantCampaign',
+        component: () => import('@/views/EquityGrantCampaign.vue'),
+        meta: { title: '发放计划' }
+      },
+      {
+        path: 'customization/hsbc/equity-grant-campaign/add',
+        name: 'EquityGrantCampaignAdd',
+        component: () => import('@/views/EquityGrantCampaignEdit.vue'),
+        meta: { title: '新建发放计划' }
+      },
+      {
+        path: 'customization/hsbc/equity-grant-campaign/edit/:id',
+        name: 'EquityGrantCampaignEdit',
+        component: () => import('@/views/EquityGrantCampaignEdit.vue'),
+        meta: { title: '编辑发放计划' }
+      },
+      {
+        path: 'customization/hsbc/equity-grant-campaign/view/:id',
+        name: 'EquityGrantCampaignView',
+        component: () => import('@/views/EquityGrantCampaignView.vue'),
+        meta: { title: '查看发放计划' }
+      }
+    ]
+  },
+  { path: '/:pathMatch(.*)*', redirect: '/' }
+]
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  routes
+})
+
+router.beforeEach((to, from, next) => {
+  const token = localStorage.getItem('token')
+  if (to.meta && to.meta.public) {
+    if (token && to.path === '/login') return next('/')
+    return next()
+  }
+  if (!token) {
+    return next({ path: '/login', query: { redirect: to.fullPath } })
+  }
+  next()
+})
+
+export default router

+ 129 - 0
pc-v3/src/store/crowdNameListStore.js

@@ -0,0 +1,129 @@
+// 客群名单 store(demo 用,刷新后重置为初始种子数据)
+import { reactive } from 'vue'
+
+const now = () => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+// A 开头 + 6 位序列 + 8 位日期 = 15 位
+function genId(seq) {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
+  return `A${String(seq).padStart(6, '0')}${stamp}`
+}
+
+const state = reactive({
+  list: [
+    {
+      id: 'A0000120251224',
+      name: '名单1',
+      description: '层层礼活动黑名单',
+      status: 'draft',
+      members: ['00001', '00002', '00003', '00004'],
+      creator: '运维A',
+      reviewer: '审核B',
+      createdAt: '2025-12-24 12:52:30',
+      updatedAt: '2025-12-31 15:31:12'
+    }
+  ],
+  seq: 2,
+  // 调整流水(demo 仅用于上传文件结果展示)
+  adjustments: []
+})
+
+function findById(id) {
+  return state.list.find((t) => t.id === id)
+}
+
+export default {
+  get list() {
+    return state.list
+  },
+  query({ id = '', name = '' } = {}) {
+    return state.list.filter((t) => {
+      if (id && !t.id.includes(id)) return false
+      if (name && !t.name.includes(name)) return false
+      return true
+    })
+  },
+  getById(id) {
+    return findById(id)
+  },
+  create(payload) {
+    const initialMembers = Array.isArray(payload.members)
+      ? Array.from(new Set(payload.members.filter((m) => m)))
+      : []
+    const record = {
+      id: genId(state.seq++),
+      name: payload.name,
+      description: payload.description || '',
+      status: 'draft',
+      members: initialMembers,
+      creator: '当前用户',
+      reviewer: '审核B',
+      createdAt: now(),
+      updatedAt: now()
+    }
+    state.list.unshift(record)
+    return record
+  },
+  update(id, payload) {
+    const target = findById(id)
+    if (!target) return null
+    target.name = payload.name
+    target.description = payload.description || ''
+    target.updatedAt = now()
+    return target
+  },
+  activate(id) {
+    const target = findById(id)
+    if (!target) return
+    target.status = 'active'
+    target.updatedAt = now()
+  },
+  stop(id) {
+    const target = findById(id)
+    if (!target) return
+    target.status = 'stopped'
+    target.updatedAt = now()
+  },
+  // 单条人员调整
+  adjustOne({ listId, customerId, direction }) {
+    const target = findById(listId)
+    if (!target) return { ok: false, reason: '名单不存在' }
+    if (target.status === 'stopped') return { ok: false, reason: '已停止名单不可调整' }
+    if (direction === 'A') {
+      if (target.members.includes(customerId)) {
+        return { ok: false, reason: '客户号已存在名单中' }
+      }
+      target.members.push(customerId)
+      target.updatedAt = now()
+      return { ok: true, action: 'add' }
+    }
+    if (direction === 'D') {
+      const idx = target.members.indexOf(customerId)
+      if (idx < 0) return { ok: false, reason: '客户号不在名单中' }
+      target.members.splice(idx, 1)
+      target.updatedAt = now()
+      return { ok: true, action: 'remove' }
+    }
+    return { ok: false, reason: '调整方向非法' }
+  },
+  // 批量调整(来自上传文件)
+  applyBatch(items) {
+    const result = { success: 0, failed: 0, failures: [] }
+    items.forEach((item, idx) => {
+      const r = this.adjustOne(item)
+      if (r.ok) {
+        result.success++
+      } else {
+        result.failed++
+        result.failures.push({ line: idx + 1, item, reason: r.reason })
+      }
+    })
+    return result
+  }
+}

+ 191 - 0
pc-v3/src/store/crowdTaskStore.js

@@ -0,0 +1,191 @@
+// 客群任务 store(demo 用,刷新后重置为初始种子数据)
+import { reactive } from 'vue'
+
+const now = () => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+// 14 位编号:6 位序列 + 8 位日期
+function genTaskId(seq) {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
+  return `${String(seq).padStart(6, '0')}${stamp}`
+}
+
+// sample 数量规则
+function sampleSize(count) {
+  if (count < 30000) return 5
+  if (count < 50000) return 8
+  if (count < 150000) return 12
+  return 15
+}
+
+function generateSample(count) {
+  const size = Math.min(sampleSize(count), Math.max(count, 0))
+  const arr = []
+  const used = new Set()
+  while (arr.length < size) {
+    const cid = String(Math.floor(Math.random() * 99999) + 1).padStart(5, '0')
+    if (!used.has(cid)) {
+      used.add(cid)
+      arr.push({ index: arr.length + 1, customerId: cid })
+    }
+  }
+  return arr
+}
+
+function randomCount() {
+  // 随机分布在 1000 - 200000
+  return Math.floor(Math.random() * 199000) + 1000
+}
+
+const state = reactive({
+  list: [
+    {
+      id: '00000120251224',
+      name: '层层礼圈选客群1',
+      description: '圈选层层礼报名客群',
+      startDate: '2025-12-25',
+      endDate: '2025-12-31',
+      ruleGroupLogic: 'AND',
+      ruleGroups: [
+        { id: 'RG20260401001', name: '层层礼-借记卡新客圈选', description: '2025/9/30 前开户 + MSC 属于卓越/运筹', logic: 'AND', ruleCount: 4 },
+        { id: 'RG20260410002', name: '信用卡-高净值持卡客户', description: 'MSC 属于卓越/尊尚 且 月末余额 ≥ 100 万', logic: 'AND', ruleCount: 2 }
+      ],
+      status: 'verifying',
+      creator: '运维A',
+      reviewer: '审核B',
+      createdAt: '2025-12-24 12:52:30',
+      updatedAt: '2025-12-31 15:31:12',
+      run: {
+        count: 20000,
+        sample: generateSample(20000),
+        fulllist: null
+      }
+    }
+  ],
+  seq: 2
+})
+
+function findById(id) {
+  return state.list.find((t) => t.id === id)
+}
+
+// 模拟"行内"异步返回
+function asyncRun(task, kind, delay = 2000) {
+  if (kind === 'countSample') {
+    task.run['count'] = 'running'
+    task.run['sample'] = 'running'
+    setTimeout(() => {
+      const c = randomCount()
+      task.run['count'] = c
+      task.run['sample'] = generateSample(c)
+      task.status = 'verifying'
+      task.updatedAt = now()
+    }, delay)
+  } else if (kind === 'fulllist') {
+    task.run['fulllist'] = 'running'
+    setTimeout(() => {
+      task.run['fulllist'] = 'completed'
+      task.status = 'online'
+      task.updatedAt = now()
+    }, delay + 1000)
+  }
+}
+
+export default {
+  get list() {
+    return state.list
+  },
+  query({ id = '', name = '' } = {}) {
+    return state.list.filter((t) => {
+      if (id && !t.id.includes(id)) return false
+      if (name && !t.name.includes(name)) return false
+      return true
+    })
+  },
+  getById(id) {
+    return findById(id)
+  },
+  create(payload) {
+    const record = {
+      id: genTaskId(state.seq++),
+      name: payload.name,
+      description: payload.description || '',
+      startDate: payload.startDate,
+      endDate: payload.endDate,
+      ruleGroupLogic: payload.ruleGroupLogic || 'AND',
+      ruleGroups: payload.ruleGroups || [],
+      status: 'draft',
+      creator: '当前用户',
+      reviewer: '',
+      createdAt: now(),
+      updatedAt: now(),
+      run: { count: null, sample: null, fulllist: null }
+    }
+    state.list.unshift(record)
+    return record
+  },
+  copyFrom(sourceId) {
+    const src = findById(sourceId)
+    if (!src) return null
+    return this.create({
+      name: src.name + '-副本',
+      description: src.description,
+      startDate: src.startDate,
+      endDate: src.endDate,
+      ruleGroupLogic: src.ruleGroupLogic,
+      ruleGroups: src.ruleGroups.map((r) => ({ ...r }))
+    })
+  },
+  update(id, payload, opts = {}) {
+    const target = findById(id)
+    if (!target) return null
+    target.name = payload.name
+    target.description = payload.description || ''
+    target.startDate = payload.startDate
+    target.endDate = payload.endDate
+    target.ruleGroupLogic = payload.ruleGroupLogic || 'AND'
+    target.ruleGroups = payload.ruleGroups || []
+    target.updatedAt = now()
+    // 编辑校验状态的任务 → 回退到新建并重置运行信息
+    if (opts.resetRun) {
+      target.status = 'draft'
+      target.run = { count: null, sample: null, fulllist: null }
+    }
+    return target
+  },
+  stop(id) {
+    const target = findById(id)
+    if (!target) return
+    target.status = 'stopped'
+    target.updatedAt = now()
+  },
+  run(id) {
+    const target = findById(id)
+    if (!target || target.status !== 'draft') return
+    asyncRun(target, 'countSample')
+  },
+  rerunCountSample(id) {
+    const target = findById(id)
+    if (!target) return
+    asyncRun(target, 'countSample')
+  },
+  publish(id) {
+    const target = findById(id)
+    if (!target || target.status !== 'verifying') return
+    target.reviewer = target.reviewer || '审核B'
+    asyncRun(target, 'fulllist')
+  },
+  // 给视图用:当前任务允许的操作列表
+  allowedActions(task) {
+    if (!task) return []
+    if (task.status === 'draft') return ['edit', 'stop', 'detail', 'run']
+    if (task.status === 'verifying') return ['edit', 'stop', 'detail', 'publish']
+    if (task.status === 'online') return ['stop', 'detail']
+    return ['detail']
+  }
+}

+ 163 - 0
pc-v3/src/store/ruleGroupStore.js

@@ -0,0 +1,163 @@
+// 规则组数据 store(demo 用,刷新后重置为初始种子数据)
+import { reactive } from 'vue'
+
+const now = () => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+const state = reactive({
+  list: [
+    {
+      id: 'RG20260401001',
+      name: '层层礼-借记卡新客圈选',
+      type: 'debitCard',
+      logic: 'AND',
+      description: '2025/9/30 前开户 + MSC 属于卓越/运筹 + TRB < 50万',
+      status: 'online',
+      creator: '张运营',
+      createdAt: '2026-04-01 09:15:30',
+      updater: '王审核',
+      updatedAt: '2026-04-05 11:20:18',
+      rules: [
+        {
+          id: 'r1',
+          metaCode: '001',
+          ruleName: '层层礼开户时间',
+          config: { accountType: 'I', operator: '<', date: '2025-09-30' }
+        },
+        {
+          id: 'r2',
+          metaCode: '010',
+          ruleName: '层层礼,MSC',
+          config: { operator: 'in', values: ['premier', 'advance'] }
+        },
+        {
+          id: 'r3',
+          metaCode: '050',
+          ruleName: 'TRB值不含家庭账户余额(连续3个月达标)',
+          config: { month: '2025-12', nm: { n: 3, m: 6 }, operator: '<', amount: 500000 }
+        },
+        {
+          id: 'r4',
+          metaCode: '051',
+          ruleName: '2025年12月月末余额(连续3个月达标)',
+          config: { month: '2025-12', nm: { n: 3, m: 6 }, operator: '<', amount: 500000 }
+        }
+      ]
+    },
+    {
+      id: 'RG20260410002',
+      name: '信用卡-高净值持卡客户',
+      type: 'creditCard',
+      logic: 'AND',
+      description: 'MSC 属于卓越/尊尚 且 月末余额 ≥ 100 万',
+      status: 'online',
+      creator: '李产品',
+      createdAt: '2026-04-10 16:55:02',
+      updater: '李产品',
+      updatedAt: '2026-04-10 16:55:02',
+      rules: [
+        {
+          id: 'r1',
+          metaCode: '010',
+          ruleName: '信用卡MSC',
+          config: { operator: 'in', values: ['premier', 'excellence'] }
+        },
+        {
+          id: 'r2',
+          metaCode: '051',
+          ruleName: '月末余额 ≥ 100万',
+          config: { month: '2026-03', nm: { n: 3, m: 6 }, operator: '>=', amount: 1000000 }
+        }
+      ]
+    },
+    {
+      id: 'RG20260420003',
+      name: '借记卡-跨境候选客群',
+      type: 'debitCard',
+      logic: 'OR',
+      description: 'TRB ≥ 50 万 或 月末余额 ≥ 50 万',
+      status: 'draft',
+      creator: '赵市场',
+      createdAt: '2026-04-20 11:48:33',
+      updater: '赵市场',
+      updatedAt: '2026-04-25 09:30:01',
+      rules: [
+        {
+          id: 'r1',
+          metaCode: '050',
+          ruleName: 'TRB ≥ 50万',
+          config: { month: '2026-04', nm: { n: 1, m: 1 }, operator: '>=', amount: 500000 }
+        }
+      ]
+    }
+  ]
+})
+
+let seq = state.list.length + 1
+
+function genId() {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
+  return `RG${stamp}${String(seq++).padStart(3, '0')}`
+}
+
+export default {
+  get list() {
+    return state.list
+  },
+  query({ id = '', name = '', type = '' } = {}) {
+    return state.list.filter((t) => {
+      if (id && !t.id.includes(id)) return false
+      if (name && !t.name.includes(name)) return false
+      if (type && t.type !== type) return false
+      return true
+    })
+  },
+  getById(id) {
+    return state.list.find((t) => t.id === id)
+  },
+  create(payload) {
+    const record = {
+      id: genId(),
+      name: payload.name,
+      type: payload.type,
+      logic: payload.logic || 'AND',
+      description: payload.description || '',
+      status: 'draft',
+      creator: '当前用户',
+      createdAt: now(),
+      updater: '当前用户',
+      updatedAt: now(),
+      rules: payload.rules || []
+    }
+    state.list.unshift(record)
+    return record
+  },
+  update(id, payload) {
+    const target = state.list.find((t) => t.id === id)
+    if (!target) return null
+    target.name = payload.name
+    target.type = payload.type
+    target.logic = payload.logic || 'AND'
+    target.description = payload.description || ''
+    target.rules = payload.rules || []
+    target.updater = '当前用户'
+    target.updatedAt = now()
+    return target
+  },
+  remove(id) {
+    const idx = state.list.findIndex((t) => t.id === id)
+    if (idx >= 0) state.list.splice(idx, 1)
+  },
+  publish(id) {
+    const target = state.list.find((t) => t.id === id)
+    if (!target) return
+    target.status = 'online'
+    target.updater = '当前用户'
+    target.updatedAt = now()
+  }
+}

+ 87 - 0
pc-v3/src/store/smsTemplateStore.js

@@ -0,0 +1,87 @@
+// 短信模板数据的会话内存储(demo 用,刷新后重置为初始种子数据)
+import { reactive } from 'vue'
+
+const now = () => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+const state = reactive({
+  list: [
+    {
+      id: 'SMS_TPL_001',
+      name: '通用权益支付成功模板',
+      content: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}',
+      creator: '张运营',
+      createdAt: '2026-04-01 09:15:30',
+      updater: '张运营',
+      updatedAt: '2026-04-10 14:20:10'
+    },
+    {
+      id: 'SMS_TPL_002',
+      name: '券码类短信模板',
+      content: '【银行】尊敬的客户,您的${productName}购买成功,券码为${exchangeNum},请于${expireDate}前使用。',
+      creator: '李产品',
+      createdAt: '2026-04-02 10:32:18',
+      updater: '李产品',
+      updatedAt: '2026-04-18 09:02:45'
+    },
+    {
+      id: 'SMS_TPL_003',
+      name: '实物类短信模板',
+      content: '【银行】您购买的${productName}已受理,我们将尽快安排发货,感谢您的支持。',
+      creator: '王运营',
+      createdAt: '2026-04-05 11:08:00',
+      updater: '王运营',
+      updatedAt: '2026-04-05 11:08:00'
+    }
+  ]
+})
+
+let seq = state.list.length
+
+const genId = () => {
+  seq += 1
+  return `SMS_TPL_${String(seq).padStart(3, '0')}`
+}
+
+export default {
+  state,
+  query({ name = '', id = '' } = {}) {
+    return state.list.filter(item => {
+      if (name && !item.name.includes(name)) return false
+      if (id && !item.id.includes(id)) return false
+      return true
+    })
+  },
+  getById(id) {
+    return state.list.find(item => item.id === id)
+  },
+  create({ name, content }) {
+    const item = {
+      id: genId(),
+      name,
+      content,
+      creator: '当前用户',
+      createdAt: now(),
+      updater: '当前用户',
+      updatedAt: now()
+    }
+    state.list.unshift(item)
+    return item
+  },
+  update(id, { name, content }) {
+    const target = state.list.find(item => item.id === id)
+    if (!target) return null
+    target.name = name
+    target.content = content
+    target.updater = '当前用户'
+    target.updatedAt = now()
+    return target
+  },
+  remove(id) {
+    const idx = state.list.findIndex(item => item.id === id)
+    if (idx >= 0) state.list.splice(idx, 1)
+  }
+}

+ 184 - 0
pc-v3/src/store/tagStore.js

@@ -0,0 +1,184 @@
+// 标签数据的会话内存储(demo 用,刷新后重置为初始种子数据)
+import { reactive } from 'vue'
+
+const now = () => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+const state = reactive({
+  list: [
+    {
+      id: 'TAG20260401001',
+      name: '近 6 个月尊尚客户',
+      description: '近 6 个月符合尊尚资格的客户,用于尊尚专享权益投放',
+      status: 'enabled',
+      creator: '张运营',
+      createdAt: '2026-04-01 09:15:30',
+      updater: '张运营',
+      updatedAt: '2026-04-10 14:20:10',
+      referenceCount: 3,
+      groups: [
+        {
+          id: 'g1',
+          logic: 'AND',
+          rules: [
+            {
+              id: 'r1',
+              paramKey: 'isQualifiedLast6Months',
+              timeWindow: { mode: 'current', month: '', n: 3, m: 1, aggregator: 'any' },
+              operator: 'eq',
+              value: true,
+              value2: '',
+              unit: 'day'
+            }
+          ]
+        }
+      ]
+    },
+    {
+      id: 'TAG20260402002',
+      name: '高净值活跃客户',
+      description: 'TRB ≥ 100万 且 近 3 个月登录过手机银行',
+      status: 'enabled',
+      creator: '李产品',
+      createdAt: '2026-04-02 10:32:18',
+      updater: '王审核',
+      updatedAt: '2026-04-18 09:02:45',
+      referenceCount: 1,
+      groups: [
+        {
+          id: 'g1',
+          logic: 'AND',
+          rules: [
+            {
+              id: 'r1',
+              paramKey: 'avgTrbIncludingHousehold',
+              timeWindow: { mode: 'current', month: '', n: 3, m: 1, aggregator: 'any' },
+              operator: 'gte',
+              value: 1000000,
+              value2: '',
+              unit: 'day'
+            },
+            {
+              id: 'r2',
+              paramKey: 'isPrevTwoMonthsActive',
+              timeWindow: { mode: 'current', month: '', n: 3, m: 1, aggregator: 'any' },
+              operator: 'eq',
+              value: true,
+              value2: '',
+              unit: 'day'
+            }
+          ]
+        }
+      ]
+    },
+    {
+      id: 'TAG20260410003',
+      name: '跨境 1+1 CN-HK 新客',
+      description: '2026 年新晋 1+1 CN-HK qualifier',
+      status: 'disabled',
+      creator: '赵市场',
+      createdAt: '2026-04-10 16:55:02',
+      updater: '赵市场',
+      updatedAt: '2026-04-20 11:48:33',
+      referenceCount: 0,
+      groups: [
+        {
+          id: 'g1',
+          logic: 'AND',
+          rules: [
+            {
+              id: 'r1',
+              paramKey: 'isHKQualifier',
+              timeWindow: { mode: 'current', month: '', n: 3, m: 1, aggregator: 'any' },
+              operator: 'eq',
+              value: true,
+              value2: '',
+              unit: 'day'
+            },
+            {
+              id: 'r2',
+              paramKey: 'cnHkQualifierDate',
+              timeWindow: { mode: 'current', month: '', n: 3, m: 1, aggregator: 'any' },
+              operator: 'gte',
+              value: '2026-01',
+              value2: '',
+              unit: 'day'
+            }
+          ]
+        }
+      ]
+    }
+  ]
+})
+
+let seq = state.list.length + 1
+
+function genId() {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
+  return `TAG${stamp}${String(seq++).padStart(3, '0')}`
+}
+
+export default {
+  get list() {
+    return state.list
+  },
+  query({ name = '', id = '', status = '', creator = '', createdRange = [] } = {}) {
+    return state.list.filter((t) => {
+      if (name && !t.name.includes(name)) return false
+      if (id && t.id !== id) return false
+      if (status && t.status !== status) return false
+      if (creator && !t.creator.includes(creator)) return false
+      if (createdRange && createdRange.length === 2 && createdRange[0] && createdRange[1]) {
+        const c = t.createdAt.slice(0, 10)
+        if (c < createdRange[0] || c > createdRange[1]) return false
+      }
+      return true
+    })
+  },
+  getById(id) {
+    return state.list.find((t) => t.id === id)
+  },
+  create(payload) {
+    const record = {
+      id: genId(),
+      name: payload.name,
+      description: payload.description || '',
+      status: payload.status ? 'enabled' : 'disabled',
+      creator: '当前用户',
+      createdAt: now(),
+      updater: '当前用户',
+      updatedAt: now(),
+      referenceCount: 0,
+      groups: payload.groups || []
+    }
+    state.list.unshift(record)
+    return record
+  },
+  update(id, payload) {
+    const target = state.list.find((t) => t.id === id)
+    if (!target) return null
+    target.name = payload.name
+    target.description = payload.description || ''
+    target.status = payload.status ? 'enabled' : 'disabled'
+    target.groups = payload.groups || []
+    target.updater = '当前用户'
+    target.updatedAt = now()
+    return target
+  },
+  remove(id) {
+    const idx = state.list.findIndex((t) => t.id === id)
+    if (idx >= 0) state.list.splice(idx, 1)
+  },
+  toggleStatus(id) {
+    const target = state.list.find((t) => t.id === id)
+    if (!target) return
+    target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
+    target.updater = '当前用户'
+    target.updatedAt = now()
+  }
+}

+ 21 - 0
pc-v3/src/styles/quill.css

@@ -0,0 +1,21 @@
+/* @vueup/vue-quill 默认不带容器尺寸,且 Element Plus 的 .el-form-item__content 是 flex 容器,
+   直接放 toolbar + container 会被 flex 拉伸。用 flex: 0 0 100% 锁死尺寸,让它们按 block 流上下排列。 */
+.ql-toolbar.ql-snow {
+  flex: 0 0 100%;
+  width: 100%;
+  box-sizing: border-box;
+}
+.ql-container.ql-snow {
+  flex: 0 0 auto;
+  width: 100%;
+  height: 180px;
+  box-sizing: border-box;
+  font-size: 14px;
+}
+.ql-editor {
+  min-height: 100%;
+}
+.ql-editor.ql-blank::before {
+  font-style: normal;
+  color: #c0c4cc;
+}

+ 19 - 0
pc-v3/src/utils/linkOptions.js

@@ -0,0 +1,19 @@
+import request from '@/utils/request'
+
+export async function fetchLinkOptions() {
+  const [campaignRes, channelRes] = await Promise.all([
+    request.get('/api/campaign/list'),
+    request.get('/api/channel/list')
+  ])
+
+  return {
+    campaignOptions: (campaignRes.data || []).map(item => ({
+      label: `${item.id} - ${item.name}`,
+      value: item.id
+    })),
+    channelOptions: (channelRes.data || []).map(item => ({
+      label: `${item.id} - ${item.subName || item.name || '-'}`,
+      value: item.id
+    }))
+  }
+}

+ 33 - 0
pc-v3/src/utils/request.js

@@ -0,0 +1,33 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+
+const request = axios.create({ timeout: 5000 })
+
+request.interceptors.request.use((config) => {
+  const token = localStorage.getItem('token')
+  if (token) {
+    config.headers.Authorization = `Bearer ${token}`
+  }
+  return config
+})
+
+request.interceptors.response.use(
+  (res) => {
+    if (res.data.code !== 200) {
+      if (res.data.code === 401) {
+        localStorage.removeItem('token')
+        localStorage.removeItem('username')
+        window.location.hash = '#/login'
+      }
+      ElMessage.error(res.data.msg || '请求失败')
+      return Promise.reject(res.data)
+    }
+    return res.data
+  },
+  (err) => {
+    ElMessage.error('网络错误')
+    return Promise.reject(err)
+  }
+)
+
+export default request

+ 1004 - 0
pc-v3/src/views/AddEquityProduct.vue

@@ -0,0 +1,1004 @@
+<template>
+  <div class="add-equity-product">
+    <el-card>
+      <template #header>
+        <div class="header-title">
+          <span>添加权益</span>
+        </div>
+      </template>
+
+      <!-- 步骤条 -->
+      <el-steps :active="activeStep" simple style="margin-bottom: 20px;">
+        <el-step title="填写权益信息">
+          <template #icon><el-icon><InfoFilled /></el-icon></template>
+        </el-step>
+        <el-step title="设置规则">
+          <template #icon><el-icon><Setting /></el-icon></template>
+        </el-step>
+        <el-step title="更多设置">
+          <template #icon><el-icon><Monitor /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- 步骤内容 -->
+      <div v-show="activeStep === 0">
+        <!-- 填写权益信息 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">基本信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益来源" required>
+                  <el-radio-group v-model="form.source">
+                    <el-radio label="银行自有"></el-radio>
+                    <el-radio label="荣数商品"></el-radio>
+                    <el-radio label="第三方"></el-radio>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '荣数商品'">
+                <el-form-item label="选择权益" required>
+                  <el-input
+                    v-model="form.rongshuProduct"
+                    placeholder="请选择权益"
+                    readonly
+                  >
+                    <template #append>
+                      <el-button @click="rongshuDialogVisible = true">选择</el-button>
+                    </template>
+                  </el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="权益类型" required>
+                  <el-radio-group v-model="form.productType">
+                    <el-radio label="券码"></el-radio>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label="外部权益链接" required>
+                  <el-select v-model="form.externalLink" placeholder="请选择外部权益链接">
+                    <el-option label="太古地产" value="太古地产"></el-option>
+                    <el-option label="龙腾" value="龙腾"></el-option>
+                    <el-option label="h5链接" value="h5链接"></el-option>
+                    <el-option label="小程序链接" value="小程序链接"></el-option>
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方' && form.externalLink === 'h5链接'" :gutter="20">
+                <el-form-item label="h5链接" required>
+                  <el-input v-model="form.customLink" placeholder="请输入h5链接"></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item required label-width="140px">
+                  <template #label>
+                    <span style="white-space: nowrap;">是否有详情页<el-tooltip content="没有详情页时直接从首页/列表页跳转" placement="top"><el-icon class="tip-icon"><QuestionFilled /></el-icon></el-tooltip></span>
+                  </template>
+                  <el-radio-group v-model="form.externalJumpMethod">
+                    <el-radio-button label="没有"></el-radio-button>
+                    <el-radio-button label="有"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.source === '第三方' && form.externalLink === '小程序链接'">
+              <el-col :span="8">
+                <el-form-item label="小程序appid" required>
+                  <el-input v-model="form.miniAppId" placeholder="请输入小程序appid"></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="小程序页面地址" required label-width="140px">
+                  <el-input v-model="form.miniAppPath" placeholder="请输入小程序页面地址"></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="权益名称" required>
+                    <el-input v-model="form.equityName" maxlength="40" placeholder="请输入权益名称"></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" maxlength="40" placeholder="请输入权益副标题"></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20" v-if="form.source === '荣数商品'">
+                <el-col :span="8">
+                  <el-form-item label="库存数" required>
+                    <el-input-number
+                      v-model="form.stockCount"
+                      :min="0"
+                      :max="100000"
+                      style="width: 100%"
+                    ></el-input-number>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item required>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <el-upload
+                      action=""
+                      :auto-upload="false"
+                      :file-list="form.listIcon"
+                      :on-change="file => handleUpload(file, 'listIcon')"
+                      :on-preview="handlePreviewImage"
+                      :on-remove="() => form.listIcon = []"
+                      :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                      :limit="1"
+                      list-type="picture-card"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                    <div class="upload-size-tip">建议尺寸:80x80px</div>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+
+            <template v-else-if="form.source === '第三方'">
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="权益名称" required>
+                    <el-input v-model="form.equityName" maxlength="40" placeholder="请输入权益名称"></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" maxlength="40" placeholder="请输入权益副标题"></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20" v-if="form.externalJumpMethod === '没有'">
+                <el-col :span="8">
+                  <el-form-item required>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <el-upload
+                      action=""
+                      :auto-upload="false"
+                      :file-list="form.listIcon"
+                      :on-change="file => handleUpload(file, 'listIcon')"
+                      :on-preview="handlePreviewImage"
+                      :on-remove="() => form.listIcon = []"
+                      :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                      :limit="1"
+                      list-type="picture-card"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                    <div class="upload-size-tip">建议尺寸:80x80px</div>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row v-if="form.externalJumpMethod === '有'" :gutter="20">
+                <el-col :span="8">
+                  <el-form-item required>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <el-upload
+                      action=""
+                      :auto-upload="false"
+                      :file-list="form.listIcon"
+                      :on-change="file => handleUpload(file, 'listIcon')"
+                      :on-preview="handlePreviewImage"
+                      :on-remove="() => form.listIcon = []"
+                      :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                      :limit="1"
+                      list-type="picture-card"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                    <div class="upload-size-tip">建议尺寸:80x80px</div>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </el-card>
+
+        <!-- 详情信息 -->
+        <el-card v-if="shouldShowDetailCard" shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">详情信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益主图" required>
+                  <el-upload
+                    action=""
+                    :auto-upload="false"
+                    :file-list="form.mainImage"
+                    :on-change="file => handleUpload(file, 'mainImage')"
+                    :on-preview="handlePreviewImage"
+                    :on-remove="() => form.mainImage = []"
+                    :class="{ 'upload-hide-trigger': form.mainImage.length >= 1 }"
+                    :limit="1"
+                    list-type="picture-card"
+                  >
+                    <el-icon><Plus /></el-icon>
+                  </el-upload>
+                  <div class="upload-size-tip">建议尺寸:716x400px</div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益介绍" required>
+                  <el-input
+                    v-model="form.introduction"
+                    type="textarea"
+                    :rows="4"
+                    maxlength="500"
+                    show-word-limit
+                    placeholder="请输入权益介绍"
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益有效期" required>
+                  <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+                    <el-radio-group v-model="form.validityType">
+                      <el-radio label="fromGrantPlan">取自发放计划</el-radio>
+                      <el-radio label="custom">自定义输入</el-radio>
+                    </el-radio-group>
+                    <el-input
+                      v-if="form.validityType === 'custom'"
+                      v-model="form.validityText"
+                      maxlength="50"
+                      show-word-limit
+                      placeholder="请输入权益有效期"
+                      style="margin-top: 8px;"
+                    ></el-input>
+                  </template>
+                  <el-input
+                    v-else
+                    v-model="form.validityText"
+                    maxlength="50"
+                    show-word-limit
+                    placeholder="请输入权益有效期"
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益内容" required>
+                  <quill-editor v-model:content="form.overview" content-type="html" :options="quillOptions"></quill-editor>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="权益说明" required>
+                  <quill-editor v-model:content="form.details" content-type="html" :options="quillOptions"></quill-editor>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20">
+                <el-col :span="24">
+                  <el-form-item>
+                    <template #label>
+                      <span>下单成功短信
+                        <el-tooltip placement="top">
+                          <template #content>
+                            此模板为权益支付成功,发送给持卡人的短信模板;<br/>
+                            开启后需选择短信模板,关闭则不发送短信。<br/>
+                            占位符如下:<br/>
+                            权益名称:${productName}<br/>
+                            结束日期:${expireDate}<br/>
+                            卡号:${exchangeNum}<br/>
+                            卡密:${exchangePwd}<br/>
+                            券码路径:${qrCodeDownPaths}
+                          </template>
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <el-switch v-model="form.smsEnabled" @change="handleSmsEnabledChange"></el-switch>
+                    <template v-if="form.smsEnabled">
+                      <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                        <el-select
+                          v-model="form.smsTemplateId"
+                          placeholder="请选择短信模板"
+                          style="width: 100%"
+                          @change="handleSmsTemplateChange"
+                        >
+                          <el-option
+                            v-for="item in smsTemplateOptions"
+                            :key="item.id"
+                            :label="item.name"
+                            :value="item.id"
+                          />
+                        </el-select>
+                        <div v-if="form.smsTemplate" class="sms-template-preview">{{ form.smsTemplate }}</div>
+                      </div>
+                    </template>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="领取成功按钮" required>
+                    <el-input v-model="form.successButtonText" placeholder="请输入按钮文案"></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </el-card>
+
+      </div>
+
+      <div v-show="activeStep === 1">
+        <!-- 设置规则 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">设置规则</div>
+          </template>
+
+          <!-- 展示规则 -->
+          <div class="rule-section">
+            <h4>展示规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="展示时间限制">
+                <el-switch v-model="form.showTimeEnabled"></el-switch>
+                <template v-if="form.showTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-date-picker
+                      v-model="form.showStartTime"
+                      type="datetime"
+                      placeholder="请选择开始时间"
+                      format="yyyy-MM-dd HH:mm:ss"
+                      value-format="yyyy-MM-dd HH:mm:ss"
+                      style="width: 100%"
+                    ></el-date-picker>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-date-picker
+                      v-model="form.showEndTime"
+                      type="datetime"
+                      placeholder="请选择结束时间"
+                      format="yyyy-MM-dd HH:mm:ss"
+                      value-format="yyyy-MM-dd HH:mm:ss"
+                      style="width: 100%"
+                    ></el-date-picker>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="展示客群限制">
+                <el-switch v-model="form.showGroupEnabled"></el-switch>
+                <template v-if="form.showGroupEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-select v-model="form.showGroupTagIds" multiple filterable placeholder="请选择客群" style="width: 100%">
+                      <el-option label="客群A" value="客群A"></el-option>
+                      <el-option label="客群B" value="客群B"></el-option>
+                      <el-option label="客群C" value="客群C"></el-option>
+                    </el-select>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+
+          <!-- 领取规则 -->
+          <div class="rule-section">
+            <h4>领取规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="领取时间限制">
+                <el-switch v-model="form.claimTimeEnabled"></el-switch>
+                <template v-if="form.claimTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-date-picker
+                      v-model="form.claimStartTime"
+                      type="datetime"
+                      placeholder="请选择开始时间"
+                      format="yyyy-MM-dd HH:mm:ss"
+                      value-format="yyyy-MM-dd HH:mm:ss"
+                      style="width: 100%"
+                    ></el-date-picker>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-date-picker
+                      v-model="form.claimEndTime"
+                      type="datetime"
+                      placeholder="请选择结束时间"
+                      format="yyyy-MM-dd HH:mm:ss"
+                      value-format="yyyy-MM-dd HH:mm:ss"
+                      style="width: 100%"
+                    ></el-date-picker>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="领取客群限制">
+                <el-switch v-model="form.claimGroupEnabled"></el-switch>
+                <template v-if="form.claimGroupEnabled">
+                  <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-alert
+                      title="需在【发放计划】中选择权益发放,未发放的权益移动端不展示"
+                      type="info"
+                      :closable="false"
+                      show-icon
+                      style="margin-bottom: 12px;"
+                    ></el-alert>
+                    <div style="font-size: 14px;color: #606266;margin-bottom: 8px;font-weight: 500;">不满足提示</div>
+                    <el-form-item label="提示内容" label-width="80px" style="margin-bottom: 12px;">
+                      <el-input v-model="form.claimDenyMessage" maxlength="200" placeholder="请输入提示内容"></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮文案" label-width="100px" style="margin-bottom: 12px;">
+                      <el-input v-model="form.claimDenyButtonText" maxlength="10" placeholder="请输入按钮文案"></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮链接" label-width="100px" style="margin-bottom: 0;">
+                      <el-input v-model="form.claimDenyButtonLink" maxlength="200" placeholder="请输入按钮链接"></el-input>
+                    </el-form-item>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </div>
+
+      <div v-show="activeStep === 2">
+        <!-- 显示页面 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">显示页面</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="权益首页/列表页">
+              <el-radio-group v-model="form.showOnHome">
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="组装页">
+              <el-radio-group v-model="form.showOnAssembly">
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+              <div
+                v-if="form.showOnAssembly === '显示'"
+                style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;"
+              >
+                <el-select
+                  v-model="form.assemblyPages"
+                  multiple
+                  filterable
+                  placeholder="请选择组装页"
+                  style="width: 100%"
+                >
+                  <el-option label="组装页A" value="组装页A"></el-option>
+                  <el-option label="组装页B" value="组装页B"></el-option>
+                  <el-option label="组装页C" value="组装页C"></el-option>
+                  <el-option label="组装页D" value="组装页D"></el-option>
+                </el-select>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 分享设置 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">分享设置</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="是否支持分享">
+              <el-switch v-model="form.shareEnabled" @change="handleShareEnabledChange"></el-switch>
+              <template v-if="form.shareEnabled">
+                <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                  <el-form-item label="分享名称" label-width="100px" required style="margin-bottom: 16px;">
+                    <el-input
+                      v-model="form.shareName"
+                      maxlength="40"
+                      placeholder="请输入分享名称"
+                    ></el-input>
+                  </el-form-item>
+                  <el-form-item label="分享图片" label-width="100px" required style="margin-bottom: 0;">
+                    <el-upload
+                      action=""
+                      :auto-upload="false"
+                      :file-list="form.shareImage"
+                      :on-change="file => handleUpload(file, 'shareImage')"
+                      :limit="1"
+                      list-type="picture-card"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                  </el-form-item>
+                </div>
+              </template>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 操作栏 -->
+      <div class="action-bar">
+        <div class="action-left">
+          <el-button
+            v-if="activeStep > 0"
+            @click="prevStep"
+          ><el-icon><ArrowLeft /></el-icon>上一步</el-button>
+        </div>
+        <div class="action-center">
+          <el-button @click="goBack">取消</el-button>
+          <el-button type="primary" plain @click="handleSaveDraft">保存草稿</el-button>
+          <el-button
+            v-if="activeStep === 2"
+            type="primary"
+            @click="handleSubmitAudit"
+          >提交审核</el-button>
+        </div>
+        <div class="action-right">
+          <el-button
+            v-if="activeStep < 2"
+            type="primary"
+            @click="nextStep"
+          >下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <rongshu-product-dialog
+      v-model:visible="rongshuDialogVisible"
+      @select="onRongshuSelect"
+    />
+
+    <el-dialog v-model="previewVisible" title="图片预览" width="640px" append-to-body>
+      <img v-if="previewImageUrl" :src="previewImageUrl" style="display:block; max-width:100%; margin:0 auto;" />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import RongshuProductDialog from '@/components/RongshuProductDialog.vue'
+import request from '@/utils/request'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'AddEquityProduct',
+  components: { RongshuProductDialog },
+  data() {
+    return {
+      activeStep: 0,
+      rongshuDialogVisible: false,
+      previewVisible: false,
+      previewImageUrl: '',
+      quillOptions: {
+        placeholder: '请输入内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      },
+      smsTemplateOptions: [
+        { id: 'SMS_TPL_001', name: '通用权益支付成功模板', content: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}' },
+        { id: 'SMS_TPL_002', name: '券码类短信模板', content: '【银行】尊敬的客户,您的${productName}购买成功,券码为${exchangeNum},请于${expireDate}前使用。' },
+        { id: 'SMS_TPL_003', name: '实物类短信模板', content: '【银行】您购买的${productName}已受理,我们将尽快安排发货,感谢您的支持。' }
+      ],
+      form: {
+        source: '银行自有',
+        rongshuProduct: '',
+        productType: '券码',
+        faceValueThreshold: '',
+        faceValueDiscount: '',
+        stockCount: null,
+        shareEnabled: false,
+        shareImage: [],
+        shareName: '',
+        externalJumpMethod: '没有',
+        externalLink: '太古地产',
+        customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+        equityName: '',
+        productId: '',
+        merchantName: '',
+        subTitle: '',
+        listIcon: [],
+        mainImage: [],
+        introduction: '',
+        validityType: 'fromGrantPlan',
+        validityText: '',
+        overview: '',
+        details: '',
+        smsEnabled: false,
+        smsTemplateId: '',
+        smsTemplate: '',
+        successButtonText: '马上去使用',
+        showTimeEnabled: false,
+        showStartTime: '',
+        showEndTime: '',
+        showGroupEnabled: false,
+        showGroupTagIds: [],
+        claimTimeEnabled: false,
+        claimStartTime: '',
+        claimEndTime: '',
+        claimGroupEnabled: false,
+        claimGroupIsPotential: false,
+        claimGroupTagId: '',
+        claimDenyMessage: '',
+        claimDenyButtonText: '',
+        claimDenyButtonLink: '',
+        showOnHome: '显示',
+        showOnAssembly: '不显示',
+        assemblyPages: []
+      }
+    }
+  },
+  computed: {
+    shouldShowDetailCard() {
+      const s = this.form.source
+      if (s === '银行自有' || s === '荣数商品') return true
+      if (s === '第三方' && this.form.externalJumpMethod === '有') return true
+      return false
+    }
+  },
+  created() {
+    const copyFrom = this.$route.query.copyFrom
+    if (copyFrom) {
+      this.loadCopySource(copyFrom)
+    }
+  },
+  methods: {
+    loadCopySource(id) {
+      const mockMap = {
+        'Q100001': {
+          source: '银行自有',
+          externalJumpMethod: '',
+          externalLink: '',
+          customLink: '',
+          miniAppId: '',
+          miniAppPath: '',
+          skuNo: 'SKU001',
+          equityName: '权益A',
+          subTitle: '副标题示例',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>权益内容内容</p>',
+          details: '<p>权益说明内容</p>',
+          smsEnabled: true,
+          smsTemplateId: 'SMS_TPL_001',
+          smsTemplate: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}',
+          successButtonText: '马上去使用',
+          showTimeEnabled: true,
+          showStartTime: '2026-01-01 00:00:00',
+          showEndTime: '2026-12-31 23:59:59',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-01-01 00:00:00',
+          claimEndTime: '2026-12-31 23:59:59',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100002': {
+          source: '第三方',
+          externalJumpMethod: '没有',
+          externalLink: '太古地产',
+          customLink: '',
+          miniAppId: '',
+          miniAppPath: '',
+          skuNo: 'SKU002',
+          equityName: '权益B',
+          subTitle: '外部列表页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '',
+          details: '',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: true,
+          showGroupTagIds: ['客群A'],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-02-01 00:00:00',
+          claimEndTime: '2026-11-30 23:59:59',
+          claimGroupEnabled: true,
+          claimGroupIsPotential: false,
+          claimGroupTagId: '客群奖励活动A',
+          claimDenyMessage: '您暂不满足领取条件',
+          claimDenyButtonText: '去升级',
+          claimDenyButtonLink: 'https://example.com/upgrade'
+        },
+        'Q100003': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: '龙腾',
+          customLink: '',
+          miniAppId: '',
+          miniAppPath: '',
+          skuNo: 'SKU003',
+          equityName: '权益C',
+          subTitle: '外部详情页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>外部详情页权益内容</p>',
+          details: '<p>外部详情页权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100004': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: 'h5链接',
+          customLink: 'https://example.com/custom',
+          skuNo: 'SKU004',
+          equityName: '权益D',
+          subTitle: '外部自定义链接副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>自定义链接权益内容</p>',
+          details: '<p>自定义链接权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        }
+      }
+
+      request.get(`/api/equity/detail?id=${id}`).then(res => {
+        const core = res.data || {}
+        const rich = mockMap[id] || {}
+        const merged = { ...rich }
+        const coreFields = ['equityName', 'source', 'productType', 'skuNo', 'merchantName', 'subTitle']
+        coreFields.forEach(k => {
+          if (core[k] !== undefined && core[k] !== null && core[k] !== '') {
+            merged[k] = core[k]
+          }
+        })
+        if (core.source === '第三方') {
+          if (core.jumpMethod) merged.externalJumpMethod = core.jumpMethod
+          if (core.jumpLink) merged.externalLink = core.jumpLink
+        }
+        if (core.showOnHome) merged.showOnHome = core.showOnHome
+        if (core.showOnAssembly) merged.showOnAssembly = core.showOnAssembly
+        if (Array.isArray(core.assemblyPages)) merged.assemblyPages = [...core.assemblyPages]
+        // 复制后清空标识字段,让用户重新命名并由后端生成新编号
+        delete merged.productId
+        Object.keys(merged).forEach(k => {
+          this.form[k] = merged[k]
+        })
+        ElMessage.success(`已复制「${core.equityName || id}」的内容,请确认后保存`)
+      }).catch(() => {
+        ElMessage.warning('未能加载源权益信息,将以空白表单创建')
+      })
+    },
+    nextStep() {
+      if (this.activeStep < 2) {
+        this.activeStep++
+      }
+    },
+    prevStep() {
+      if (this.activeStep > 0) {
+        this.activeStep--
+      }
+    },
+    buildPayload() {
+      const f = this.form
+      const isCustomValidity = f.source === '第三方' || f.validityType === 'custom'
+      return {
+        equityName: f.equityName,
+        source: f.source,
+        productType: f.productType || '',
+        skuNo: f.skuNo || '',
+        merchantName: f.merchantName || '',
+        subTitle: f.subTitle || '',
+        jumpMethod: f.source === '第三方' ? f.externalJumpMethod : '-',
+        jumpLink: f.source === '第三方' ? f.externalLink : '-',
+        exchangeMethod: f.source === '第三方'
+          ? (f.externalJumpMethod === '有' ? '第三方(详情页跳转)' : '第三方(列表跳转)')
+          : '权益平台',
+        validityType: isCustomValidity ? 'custom' : 'fromGrantPlan',
+        validityText: isCustomValidity ? (f.validityText || '') : '',
+        showOnHome: f.showOnHome,
+        showOnAssembly: f.showOnAssembly,
+        assemblyPages: [...(f.assemblyPages || [])]
+      }
+    },
+    handleSaveDraft() {
+      if (!this.form.equityName) {
+        ElMessage.warning('请填写权益名称后再保存草稿')
+        return
+      }
+      request.post('/api/equity/save-draft', this.buildPayload()).then(() => {
+        ElMessage.success('草稿已保存')
+        this.$router.push('/customization/hsbc/equity-products')
+      }).catch(() => {})
+    },
+    handleSubmitAudit() {
+      if (!this.form.equityName) {
+        ElMessage.warning('请填写权益名称后再提交')
+        return
+      }
+      ElMessageBox.prompt('请填写提交说明(可选)', '提交审核', {
+        confirmButtonText: '提交',
+        cancelButtonText: '取消',
+        inputType: 'textarea',
+        inputPlaceholder: '可填写本次提交的关键变更点',
+        inputValidator: () => true
+      }).then(({ value }) => {
+        const payload = { ...this.buildPayload(), submitReason: value || '' }
+        request.post('/api/equity/submit', payload).then(() => {
+          ElMessage.success('已提交审核')
+          this.$router.push('/customization/hsbc/equity-products')
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    goBack() {
+      this.$router.push('/customization/hsbc/equity-products')
+    },
+    handleShareEnabledChange(val) {
+      if (val) {
+        if (!this.form.shareName) {
+          this.form.shareName = this.form.equityName || ''
+        }
+      }
+    },
+    handleSmsEnabledChange(val) {
+      if (!val) {
+        this.form.smsTemplateId = ''
+        this.form.smsTemplate = ''
+      }
+    },
+    handleSmsTemplateChange(id) {
+      const tpl = this.smsTemplateOptions.find(i => i.id === id)
+      this.form.smsTemplate = tpl ? tpl.content : ''
+    },
+    handleUpload(file, field) {
+      if (!file || !file.raw) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        this.form[field] = [{ name: file.name, url: e.target.result, uid: file.uid }]
+      }
+      reader.readAsDataURL(file.raw)
+    },
+    handlePreviewImage(file) {
+      this.previewImageUrl = file.url || ''
+      this.previewVisible = !!this.previewImageUrl
+    },
+    onRongshuSelect(product) {
+      this.form.rongshuProduct = product.name
+      this.form.equityName = product.name
+      this.form.merchantName = product.merchant
+      this.form.stockCount = product.stock
+      this.form.mainImage = product.mainImage ? [...product.mainImage] : []
+    }
+  }
+}
+</script>
+
+<style scoped>
+.add-equity-product {
+  padding: 20px;
+}
+.header-title {
+  font-size: 18px;
+  font-weight: 500;
+}
+.section-card {
+  margin-bottom: 16px;
+  border: none;
+}
+.upload-hide-trigger >>> .el-upload--picture-card { display: none; }
+
+.section-card >>> .el-card__header {
+  padding: 12px 20px;
+  background: #fafafa;
+  border-bottom: 1px solid #ebeef5;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+.action-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+.action-left, .action-right {
+  width: 120px;
+}
+.action-center {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+.tip-icon {
+  color: #909399;
+  cursor: pointer;
+  font-size: 14px;
+}
+.upload-size-tip {
+  margin-top: 4px;
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
+.sms-template-preview {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 548 - 0
pc-v3/src/views/AuditDetail.vue

@@ -0,0 +1,548 @@
+<template>
+  <div class="audit-detail" v-if="audit">
+    <el-card>
+      <!-- 步骤条 -->
+      <el-steps :active="activeStep" simple style="margin-bottom: 20px;">
+        <el-step title="权益信息">
+          <template #icon><el-icon><InfoFilled /></el-icon></template>
+        </el-step>
+        <el-step title="设置规则">
+          <template #icon><el-icon><Setting /></el-icon></template>
+        </el-step>
+        <el-step title="更多设置">
+          <template #icon><el-icon><Monitor /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- 步骤 0:权益信息 -->
+      <div v-show="activeStep === 0">
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">基本信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益来源">
+                  <el-radio-group v-model="form.source" disabled>
+                    <el-radio-button label="银行自有"></el-radio-button>
+                    <el-radio-button label="荣数商品"></el-radio-button>
+                    <el-radio-button label="第三方"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="权益类型">
+                  <el-radio-group v-model="form.productType" disabled>
+                    <el-radio-button label="券码"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="商户名称">
+                  <el-input :value="form.merchantName" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label="外部权益链接">
+                  <el-input :value="form.externalLink" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label="是否有详情页">
+                  <el-radio-group v-model="form.externalJumpMethod" disabled>
+                    <el-radio-button label="没有"></el-radio-button>
+                    <el-radio-button label="有"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益名称">
+                  <el-input :value="form.equityName" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="权益副标题">
+                  <el-input :value="form.subTitle" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="SIP商品编号">
+                  <el-input :value="form.skuNo" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益图标">
+                  <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                    <img :src="form.listIcon[0]" class="preview-img" />
+                  </div>
+                  <span v-else class="no-data">暂无图片</span>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </el-form>
+        </el-card>
+
+        <el-card v-if="shouldShowDetailCard" shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">详情信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益主图">
+                  <div v-if="form.mainImage && form.mainImage.length" class="image-preview">
+                    <img :src="form.mainImage[0]" class="preview-img" />
+                  </div>
+                  <span v-else class="no-data">暂无图片</span>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益介绍">
+                  <el-input :value="form.introduction" type="textarea" :rows="4" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益有效期">
+                  <el-input
+                    :value="form.validityType === 'fromGrantPlan' ? '取自发放计划' : (form.validityText || '-')"
+                    readonly
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益内容">
+                  <div class="rich-text-view" v-html="form.overview || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="权益说明">
+                  <div class="rich-text-view" v-html="form.details || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-col :span="8">
+                <el-form-item label="领取成功按钮">
+                  <el-input :value="form.successButtonText" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 步骤 1:规则 -->
+      <div v-show="activeStep === 1">
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">展示规则</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="展示时间限制">
+              <el-switch v-model="form.showTimeEnabled" disabled></el-switch>
+              <template v-if="form.showTimeEnabled">
+                <div class="rule-pane">
+                  <el-input :value="form.showStartTime" readonly style="width: 240px;"></el-input>
+                  <span style="margin: 0 8px;">-</span>
+                  <el-input :value="form.showEndTime" readonly style="width: 240px;"></el-input>
+                </div>
+              </template>
+            </el-form-item>
+            <el-form-item label="展示客群限制">
+              <el-switch v-model="form.showGroupEnabled" disabled></el-switch>
+              <template v-if="form.showGroupEnabled">
+                <div class="rule-pane">
+                  <el-input :value="(form.showGroupTagIds || []).join('、')" readonly></el-input>
+                </div>
+              </template>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">领取规则</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="领取时间限制">
+              <el-switch v-model="form.claimTimeEnabled" disabled></el-switch>
+              <template v-if="form.claimTimeEnabled">
+                <div class="rule-pane">
+                  <el-input :value="form.claimStartTime" readonly style="width: 240px;"></el-input>
+                  <span style="margin: 0 8px;">-</span>
+                  <el-input :value="form.claimEndTime" readonly style="width: 240px;"></el-input>
+                </div>
+              </template>
+            </el-form-item>
+            <el-form-item label="领取客群限制">
+              <el-switch v-model="form.claimGroupEnabled" disabled></el-switch>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 步骤 2:更多设置 -->
+      <div v-show="activeStep === 2">
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">显示页面</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="权益首页/列表页">
+              <el-radio-group v-model="form.showOnHome" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="组装页">
+              <el-radio-group v-model="form.showOnAssembly" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+              <div v-if="form.showOnAssembly === '显示'" class="rule-pane">
+                <el-input :value="(form.assemblyPages || []).join(',')" readonly></el-input>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">分享设置</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="是否支持分享">
+              <el-switch v-model="form.shareEnabled" disabled></el-switch>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 步骤导航 -->
+      <div class="step-navigation">
+        <el-button v-if="activeStep > 0" @click="prevStep"><el-icon><ArrowLeft /></el-icon>上一步</el-button>
+        <el-button
+          v-if="activeStep < 2"
+          type="primary"
+          @click="nextStep"
+          style="margin-left: auto;"
+        >下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
+      </div>
+
+      <!-- 已审记录 -->
+      <el-card v-if="audit.auditStatus !== 'pending'" shadow="never" class="section-card audit-action-card">
+        <template #header>
+          <div class="section-title">审核记录</div>
+        </template>
+        <el-form label-width="120px" label-position="left">
+          <el-row :gutter="20">
+            <el-col :span="8">
+              <el-form-item label="审核人">
+                <span>{{ audit.auditorName || '-' }}</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="审核时间">
+                <span>{{ audit.auditTime || '-' }}</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="审核结果">
+                <el-tag :type="auditStatusTag(audit.auditStatus)" size="small">
+                  {{ auditStatusLabel(audit.auditStatus) }}
+                </el-tag>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </el-card>
+
+      <!-- 操作栏 -->
+      <div class="action-bar">
+        <el-button @click="goBack">返回</el-button>
+        <template v-if="canAudit">
+          <el-button type="danger" @click="handleReject">驳回</el-button>
+          <el-button type="primary" @click="handleApprove">通过</el-button>
+        </template>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+const detailMockMap = {
+  'Q100001': {
+    listIcon: [], mainImage: [],
+    introduction: '权益A 介绍内容示例',
+    overview: '<p>权益内容内容</p>',
+    details: '<p>权益说明内容</p>',
+    successButtonText: '马上去使用',
+    showTimeEnabled: true,
+    showStartTime: '2026-01-01 00:00:00',
+    showEndTime: '2026-12-31 23:59:59',
+    showGroupEnabled: false,
+    showGroupTagIds: [],
+    claimTimeEnabled: true,
+    claimStartTime: '2026-01-01 00:00:00',
+    claimEndTime: '2026-12-31 23:59:59',
+    claimGroupEnabled: false
+  },
+  'Q100002': {
+    externalJumpMethod: '没有', externalLink: '太古地产',
+    listIcon: [], mainImage: [], introduction: '',
+    overview: '', details: '',
+    successButtonText: '',
+    showTimeEnabled: false, showStartTime: '', showEndTime: '',
+    showGroupEnabled: true, showGroupTagIds: ['客群A'],
+    claimTimeEnabled: true,
+    claimStartTime: '2026-02-01 00:00:00',
+    claimEndTime: '2026-11-30 23:59:59',
+    claimGroupEnabled: true
+  },
+  'Q100003': {
+    externalJumpMethod: '有', externalLink: '龙腾',
+    listIcon: [], mainImage: [],
+    introduction: '权益C 介绍内容示例',
+    overview: '<p>外部详情页权益内容</p>',
+    details: '<p>外部详情页权益说明</p>',
+    showTimeEnabled: false,
+    claimTimeEnabled: false,
+    claimGroupEnabled: false
+  },
+  'Q100004': {
+    externalJumpMethod: '有', externalLink: 'h5链接',
+    listIcon: [], mainImage: [],
+    introduction: '权益D 介绍内容示例',
+    overview: '<p>自定义链接权益内容</p>',
+    details: '<p>自定义链接权益说明</p>',
+    showTimeEnabled: false,
+    claimTimeEnabled: false,
+    claimGroupEnabled: false
+  }
+}
+
+export default {
+  name: 'AuditDetail',
+  data() {
+    return {
+      audit: null,
+      activeStep: 0,
+      form: {
+        source: '银行自有',
+        productType: '',
+        externalJumpMethod: '',
+        externalLink: '',
+        equityName: '',
+        subTitle: '',
+        skuNo: '',
+        merchantName: '',
+        listIcon: [],
+        mainImage: [],
+        introduction: '',
+        validityType: 'fromGrantPlan',
+        validityText: '',
+        overview: '',
+        details: '',
+        successButtonText: '',
+        showTimeEnabled: false,
+        showStartTime: '',
+        showEndTime: '',
+        showGroupEnabled: false,
+        showGroupTagIds: [],
+        claimTimeEnabled: false,
+        claimStartTime: '',
+        claimEndTime: '',
+        claimGroupEnabled: false,
+        showOnHome: '不显示',
+        showOnAssembly: '不显示',
+        assemblyPages: [],
+        shareEnabled: false
+      }
+    }
+  },
+  computed: {
+    shouldShowDetailCard() {
+      const s = this.form.source
+      if (s === '银行自有' || s === '荣数商品') return true
+      if (s === '第三方' && this.form.externalJumpMethod === '有') return true
+      return false
+    },
+    canAudit() {
+      if (!this.audit || this.audit.auditStatus !== 'pending') return false
+      const t = this.audit.auditType
+      if (t === 'new') return this.activeStep === 2
+      return true
+    }
+  },
+  created() {
+    this.loadAudit()
+  },
+  methods: {
+    loadAudit() {
+      const id = this.$route.params.id
+      request.get(`/api/audit/detail?id=${id}`).then(res => {
+        this.audit = res.data
+        if (this.audit) this.populateForm()
+      }).catch(() => {})
+    },
+    populateForm() {
+      const bizId = this.audit.bizId
+      // 1) 先用 mockMap 填充丰富的详情字段
+      if (detailMockMap[bizId]) {
+        Object.assign(this.form, detailMockMap[bizId])
+      }
+      // 2) 用 audit snapshot 覆盖(变更类型时会包含最新内容)
+      Object.assign(this.form, this.audit.snapshot || {})
+      // 3) 推导第三方相关字段
+      if (this.form.source === '第三方') {
+        if (this.form.jumpLink && !this.form.externalLink) this.form.externalLink = this.form.jumpLink
+        if (this.form.jumpMethod && !this.form.externalJumpMethod) {
+          this.form.externalJumpMethod = this.form.jumpMethod
+        }
+      }
+    },
+    auditTypeLabel(t) {
+      return { new: '新增', offshelf: '下架' }[t] || t
+    },
+    auditTypeTag(t) {
+      return { new: 'success', offshelf: 'danger' }[t] || ''
+    },
+    auditStatusLabel(s) {
+      return { pending: '待审核', approved: '已通过', rejected: '已驳回' }[s] || s
+    },
+    auditStatusTag(s) {
+      return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || ''
+    },
+    nextStep() { if (this.activeStep < 2) this.activeStep++ },
+    prevStep() { if (this.activeStep > 0) this.activeStep-- },
+    handleApprove() {
+      ElMessageBox.confirm('确定通过该审核单吗?', '提示', {
+        confirmButtonText: '通过',
+        cancelButtonText: '取消',
+        type: 'success'
+      }).then(() => {
+        request.post('/api/audit/approve', { id: this.audit.id }).then(() => {
+          ElMessage.success('审核通过')
+          this.$router.push('/customization/hsbc/audit')
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    handleReject() {
+      ElMessageBox.confirm('确定驳回该审核单吗?', '提示', {
+        confirmButtonText: '驳回',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        request.post('/api/audit/reject', { id: this.audit.id }).then(() => {
+          ElMessage.success('已驳回')
+          this.$router.push('/customization/hsbc/audit')
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    goBack() {
+      this.$router.push('/customization/hsbc/audit')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.audit-detail {
+  padding: 0;
+}
+.header-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.header-left .title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+.section-card {
+  margin-bottom: 16px;
+  border: none;
+}
+.section-card >>> .el-card__header {
+  padding: 12px 20px;
+  background: #fafafa;
+  border-bottom: 1px solid #ebeef5;
+}
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+.offshelf-reason {
+  color: #f56c6c;
+  font-weight: 500;
+}
+.rule-pane {
+  display: flex;
+  align-items: center;
+  width: 600px;
+  background: #f1f2f5;
+  padding: 12px 20px;
+  margin-top: 10px;
+}
+.image-preview {
+  width: 80px;
+  height: 80px;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.preview-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+.no-data {
+  color: #c0c4cc;
+  font-size: 13px;
+}
+.rich-text-view {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  padding: 8px 12px;
+  min-height: 80px;
+  font-size: 14px;
+  color: #606266;
+  line-height: 1.6;
+}
+.step-navigation {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+.audit-action-card {
+  margin-top: 8px;
+}
+.action-bar {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+</style>

+ 778 - 0
pc-v3/src/views/AuditEquityProduct.vue

@@ -0,0 +1,778 @@
+<template>
+  <div class="audit-equity-product">
+    <el-card>
+      <template #header>
+        <div class="header-title">
+          <span>审核权益</span>
+        </div>
+      </template>
+
+      <!-- 步骤条 -->
+      <el-steps :active="activeStep" simple style="margin-bottom: 20px;">
+        <el-step title="填写权益信息">
+          <template #icon><el-icon><InfoFilled /></el-icon></template>
+        </el-step>
+        <el-step title="设置规则">
+          <template #icon><el-icon><Setting /></el-icon></template>
+        </el-step>
+        <el-step title="更多设置">
+          <template #icon><el-icon><Monitor /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- 步骤内容 -->
+      <div v-show="activeStep === 0">
+        <!-- 基本信息 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">基本信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益来源">
+                  <el-radio-group v-model="form.source" disabled>
+                    <el-radio-button label="银行自有"></el-radio-button>
+                    <el-radio-button label="荣数商品"></el-radio-button>
+                    <el-radio-button label="第三方"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '荣数商品'">
+                <el-form-item label="选择权益">
+                  <el-input :value="form.rongshuProduct" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="权益类型">
+                  <el-radio-group v-model="form.productType" disabled>
+                    <el-radio-button label="券码"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="商户名称">
+                  <el-input :value="form.merchantName" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label="外部权益链接">
+                  <el-select v-model="form.externalLink" placeholder="请选择外部权益链接" disabled>
+                    <el-option label="太古地产" value="太古地产"></el-option>
+                    <el-option label="龙腾" value="龙腾"></el-option>
+                    <el-option label="h5链接" value="h5链接"></el-option>
+                    <el-option label="小程序链接" value="小程序链接"></el-option>
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方' && form.externalLink === 'h5链接'" :gutter="20">
+                <el-form-item label="h5链接">
+                  <el-input v-model="form.customLink" placeholder="请输入h5链接" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label-width="140px">
+                  <template #label>
+                    <span style="white-space: nowrap;">是否有详情页<el-tooltip content="没有详情页时直接从首页/列表页跳转" placement="top"><el-icon class="tip-icon"><QuestionFilled /></el-icon></el-tooltip></span>
+                  </template>
+                  <el-radio-group v-model="form.externalJumpMethod" disabled>
+                    <el-radio-button label="没有"></el-radio-button>
+                    <el-radio-button label="有"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.source === '第三方' && form.externalLink === '小程序链接'">
+              <el-col :span="8">
+                <el-form-item label="小程序appid">
+                  <el-input v-model="form.miniAppId" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="小程序页面地址" label-width="140px">
+                  <el-input v-model="form.miniAppPath" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20" v-if="form.source === '荣数商品'">
+                <el-col :span="8">
+                  <el-form-item label="库存数">
+                    <el-input :value="form.stockCount" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8" v-if="form.source === '银行自有'">
+                  <el-form-item label="SIP商品编号">
+                    <el-input v-model="form.skuNo" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益名称">
+                    <el-input v-model="form.equityName" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+
+            <template v-else-if="form.source === '第三方'">
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="权益名称">
+                    <el-input v-model="form.equityName" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20" v-if="form.externalJumpMethod === '没有'">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row v-if="form.externalJumpMethod === '有'" :gutter="20">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </el-card>
+
+        <!-- 详情信息 -->
+        <el-card v-if="shouldShowDetailCard" shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">详情信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益主图">
+                  <div v-if="form.mainImage && form.mainImage.length" class="image-preview">
+                    <img :src="form.mainImage[0]" class="preview-img" />
+                  </div>
+                  <span v-else class="no-data">暂无图片</span>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益介绍">
+                  <el-input
+                    v-model="form.introduction"
+                    type="textarea"
+                    :rows="4"
+                    readonly
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益有效期">
+                  <el-input
+                    :value="form.validityType === 'fromGrantPlan' ? '取自发放计划' : (form.validityText || '-')"
+                    readonly
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益内容">
+                  <div class="rich-text-view" v-html="form.overview || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="权益说明">
+                  <div class="rich-text-view" v-html="form.details || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20">
+                <el-col :span="24">
+                  <el-form-item label="下单成功短信">
+                    <el-switch v-model="form.smsEnabled" disabled></el-switch>
+                    <template v-if="form.smsEnabled">
+                      <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                        <el-select
+                          v-model="form.smsTemplateId"
+                          placeholder="请选择短信模板"
+                          style="width: 100%"
+                          disabled
+                        >
+                          <el-option
+                            v-for="item in smsTemplateOptions"
+                            :key="item.id"
+                            :label="item.name"
+                            :value="item.id"
+                          />
+                        </el-select>
+                        <div v-if="form.smsTemplate" class="sms-template-preview">{{ form.smsTemplate }}</div>
+                      </div>
+                    </template>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="领取成功按钮">
+                    <el-input :value="form.successButtonText" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </el-card>
+
+      </div>
+
+      <div v-show="activeStep === 1">
+        <!-- 设置规则 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">设置规则</div>
+          </template>
+          
+          <!-- 展示规则 -->
+          <div class="rule-section">
+            <h4>展示规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="展示时间限制">
+                <el-switch v-model="form.showTimeEnabled" disabled></el-switch>
+                <template v-if="form.showTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="form.showStartTime" readonly style="width: 100%"></el-input>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-input :value="form.showEndTime" readonly style="width: 100%"></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="展示客群限制">
+                <el-switch v-model="form.showGroupEnabled" disabled></el-switch>
+                <template v-if="form.showGroupEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="(form.showGroupTagIds || []).join('、')" readonly></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+
+          <!-- 领取规则 -->
+          <div class="rule-section">
+            <h4>领取规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="领取时间限制">
+                <el-switch v-model="form.claimTimeEnabled" disabled></el-switch>
+                <template v-if="form.claimTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="form.claimStartTime" readonly style="width: 100%"></el-input>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-input :value="form.claimEndTime" readonly style="width: 100%"></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="领取客群限制">
+                <el-switch v-model="form.claimGroupEnabled" disabled></el-switch>
+                <template v-if="form.claimGroupEnabled">
+                  <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-alert
+                      title="需在【发放计划】中选择权益发放,未发放的权益移动端不展示"
+                      type="info"
+                      :closable="false"
+                      show-icon
+                      style="margin-bottom: 12px;"
+                    ></el-alert>
+                    <div style="font-size: 14px;color: #606266;margin-bottom: 8px;font-weight: 500;">不满足提示</div>
+                    <el-form-item label="提示内容" label-width="80px" style="margin-bottom: 12px;">
+                      <el-input :value="form.claimDenyMessage" readonly></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮文案" label-width="100px" style="margin-bottom: 12px;">
+                      <el-input :value="form.claimDenyButtonText" readonly></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮链接" label-width="100px" style="margin-bottom: 0;">
+                      <el-input :value="form.claimDenyButtonLink" readonly></el-input>
+                    </el-form-item>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </div>
+
+      <div v-show="activeStep === 2">
+        <!-- 显示页面 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">显示页面</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="权益首页/列表页">
+              <el-radio-group v-model="form.showOnHome" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="组装页">
+              <el-radio-group v-model="form.showOnAssembly" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+              <div
+                v-if="form.showOnAssembly === '显示'"
+                style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;"
+              >
+                <el-input :value="form.assemblyPages.join(',')" readonly></el-input>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 分享设置 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">分享设置</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="是否支持分享">
+              <el-switch v-model="form.shareEnabled" disabled></el-switch>
+              <template v-if="form.shareEnabled">
+                <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                  <el-form-item label="分享名称" label-width="100px" style="margin-bottom: 16px;">
+                    <el-input :value="form.shareName" readonly></el-input>
+                  </el-form-item>
+                  <el-form-item label="分享图片" label-width="100px" style="margin-bottom: 0;">
+                    <div v-if="form.shareImage && form.shareImage.length" class="image-preview">
+                      <img :src="(form.shareImage[0] && form.shareImage[0].url) || ''" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </div>
+              </template>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 步骤导航 -->
+      <div class="step-navigation">
+        <el-button
+          v-if="activeStep > 0"
+          @click="prevStep"
+        ><el-icon><ArrowLeft /></el-icon>上一步</el-button>
+        <el-button
+          v-if="activeStep < 2"
+          type="primary"
+          @click="nextStep"
+          style="margin-left: auto;"
+        ><el-icon><ArrowRight /></el-icon>下一步</el-button>
+      </div>
+
+      <div class="action-footer">
+        <el-button @click="goBack">返回</el-button>
+        <template v-if="activeStep === 2">
+          <el-button type="danger" @click="handleReject">审核拒绝</el-button>
+          <el-button type="primary" @click="handleApprove">审核通过</el-button>
+        </template>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'AuditEquityProduct',
+  data() {
+    return {
+      activeStep: 0,
+      smsTemplateOptions: [
+        { id: 'SMS_TPL_001', name: '通用权益支付成功模板', content: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}' },
+        { id: 'SMS_TPL_002', name: '券码类短信模板', content: '【银行】尊敬的客户,您的${productName}购买成功,券码为${exchangeNum},请于${expireDate}前使用。' },
+        { id: 'SMS_TPL_003', name: '实物类短信模板', content: '【银行】您购买的${productName}已受理,我们将尽快安排发货,感谢您的支持。' }
+      ],
+      form: {
+        source: '银行自有',
+        rongshuProduct: '',
+        productType: '券码',
+        faceValueThreshold: null,
+        faceValueDiscount: null,
+        stockCount: null,
+        shareEnabled: false,
+        shareImage: [],
+        shareName: '',
+        externalJumpMethod: '没有',
+        externalLink: '太古地产',
+        customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+        skuNo: '',
+        equityName: '',
+        productId: '',
+        merchantName: '',
+        subTitle: '',
+        listIcon: [],
+        mainImage: [],
+        introduction: '',
+        validityType: 'fromGrantPlan',
+        validityText: '',
+        overview: '',
+        details: '',
+        smsEnabled: false,
+        smsTemplateId: '',
+        smsTemplate: '',
+        successButtonText: '',
+        showTimeEnabled: false,
+        showStartTime: '',
+        showEndTime: '',
+        showGroupEnabled: false,
+        showGroupTagIds: [],
+        claimTimeEnabled: false,
+        claimStartTime: '',
+        claimEndTime: '',
+        claimGroupEnabled: false,
+        claimGroupIsPotential: false,
+        claimGroupTagId: '',
+        claimDenyMessage: '',
+        claimDenyButtonText: '',
+        claimDenyButtonLink: '',
+        showOnHome: '显示',
+        showOnAssembly: '不显示',
+        assemblyPages: []
+      }
+    }
+  },
+  computed: {
+    shouldShowDetailCard() {
+      const s = this.form.source
+      if (s === '银行自有' || s === '荣数商品') return true
+      if (s === '第三方' && this.form.externalJumpMethod === '有') return true
+      return false
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    loadData() {
+      const id = this.$route.params.id
+      // TODO: 根据 id 从接口获取数据,这里用 mock 数据示例
+      const mockMap = {
+        'Q100001': {
+          source: '银行自有',
+          externalJumpMethod: '',
+          externalLink: '',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU001',
+          equityName: '权益A',
+          subTitle: '副标题示例',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>权益内容内容</p>',
+          details: '<p>权益说明内容</p>',
+          smsEnabled: true,
+          smsTemplateId: 'SMS_TPL_001',
+          smsTemplate: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}',
+          successButtonText: '马上去使用',
+          showTimeEnabled: true,
+          showStartTime: '2026-01-01 00:00:00',
+          showEndTime: '2026-12-31 23:59:59',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-01-01 00:00:00',
+          claimEndTime: '2026-12-31 23:59:59',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100002': {
+          source: '第三方',
+          externalJumpMethod: '没有',
+          externalLink: '太古地产',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU002',
+          equityName: '权益B',
+          subTitle: '外部列表页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '',
+          details: '',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: true,
+          showGroupTagIds: ['客群A'],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-02-01 00:00:00',
+          claimEndTime: '2026-11-30 23:59:59',
+          claimGroupEnabled: true,
+          claimGroupIsPotential: false,
+          claimGroupTagId: '客群奖励活动A',
+          claimDenyMessage: '您暂不满足领取条件',
+          claimDenyButtonText: '了解更多',
+          claimDenyButtonLink: 'https://example.com'
+        },
+        'Q100003': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: '龙腾',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU003',
+          equityName: '权益C',
+          subTitle: '外部详情页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>详情页权益内容</p>',
+          details: '<p>详情页权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: true,
+          showStartTime: '2026-03-01 00:00:00',
+          showEndTime: '2026-10-31 23:59:59',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100004': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: 'h5链接',
+          customLink: 'https://custom-link.com',
+          skuNo: 'SKU004',
+          equityName: '权益D',
+          subTitle: '自定义链接副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>自定义链接权益内容</p>',
+          details: '<p>自定义链接权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        }
+      }
+      if (mockMap[id]) {
+        this.form = { ...this.form, ...mockMap[id] }
+      }
+    },
+    goBack() {
+      this.$router.go(-1)
+    },
+    handleReject() {
+      ElMessageBox.confirm('确定要审核拒绝吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        ElMessage.success('审核拒绝成功')
+        this.$router.push('/customization/hsbc/equity-products')
+      }).catch(() => {
+        ElMessage.info('已取消审核拒绝')
+      })
+    },
+    nextStep() {
+      if (this.activeStep < 2) {
+        this.activeStep++
+      }
+    },
+    prevStep() {
+      if (this.activeStep > 0) {
+        this.activeStep--
+      }
+    },
+    handleApprove() {
+      ElMessageBox.confirm('确定要审核通过吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'success'
+      }).then(() => {
+        ElMessage.success('审核通过成功')
+        this.$router.push('/customization/hsbc/equity-products')
+      }).catch(() => {
+        ElMessage.info('已取消审核通过')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.audit-equity-product {
+  padding: 20px;
+}
+.tip-icon {
+  color: #909399;
+  cursor: pointer;
+  font-size: 14px;
+}
+
+.header-title {
+  font-size: 18px;
+  font-weight: bold;
+}
+
+.section-card {
+  margin-top: 20px;
+}
+
+.section-title {
+  font-size: 16px;
+  font-weight: bold;
+}
+
+.image-preview {
+  display: inline-block;
+}
+
+.preview-img {
+  width: 100px;
+  height: 100px;
+  object-fit: cover;
+  border: 1px solid #ddd;
+}
+
+.no-data {
+  color: #999;
+  font-style: italic;
+}
+
+.rich-text-view {
+  padding: 10px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  min-height: 100px;
+  background-color: #f9f9f9;
+}
+
+.rule-section {
+  margin-bottom: 24px;
+}
+.rule-section h4 {
+  font-size: 14px;
+  font-weight: 600;
+  color: #606266;
+  margin-bottom: 16px;
+  border-bottom: 1px solid #ebeef5;
+  padding-bottom: 8px;
+}
+
+.step-navigation {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+
+.action-footer {
+  display: flex;
+  justify-content: center;
+  gap: 10px;
+  margin-top: 20px;
+}
+
+.sms-template-preview {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 167 - 0
pc-v3/src/views/AuditList.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="audit-list">
+    <el-card>
+      <el-tabs v-model="activeTab" @tab-click="loadData">
+        <el-tab-pane label="待审核" name="pending"></el-tab-pane>
+        <el-tab-pane label="审核通过" name="approved"></el-tab-pane>
+        <el-tab-pane label="审核驳回" name="rejected"></el-tab-pane>
+      </el-tabs>
+      <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="80px" inline>
+            <el-form-item label="权益编号:">
+              <el-input v-model="searchForm.bizId" placeholder="请输入" style="width: 150px"></el-input>
+            </el-form-item>
+            <div class="button-group" style="margin-left: 20px;">
+              <el-button type="primary" @click="loadData">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+      </div>
+
+      <el-table :data="tableData" stripe style="width: 100%">
+        <el-table-column label="操作" min-width="140" fixed="left">
+          <template #default="scope">
+            <el-button
+              v-if="activeTab === 'pending'"
+              type="text"
+              size="small"
+              @click="goDetail(scope.row.id)"
+            >审批</el-button>
+            <el-button
+              v-else
+              type="text"
+              size="small"
+              @click="goDetail(scope.row.id)"
+            >查看</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="bizName" label="权益名称" min-width="140" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="bizId" label="权益编号" min-width="140"></el-table-column>
+        <el-table-column label="审核类型" min-width="140">
+          <template #default="scope">
+            <el-tag :type="auditTypeTag(scope.row.auditType)" size="small">
+              {{ auditTypeLabel(scope.row.auditType) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="bizModuleName" label="业务模块" min-width="140"></el-table-column>
+        <el-table-column prop="submitterName" label="提交人" min-width="140"></el-table-column>
+        <el-table-column prop="submitTime" label="提交时间" min-width="180"></el-table-column>
+        <el-table-column prop="auditorName" label="审核人" min-width="140">
+          <template #default="scope">
+            <span>{{ scope.row.auditorName || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="auditTime" label="审核时间" min-width="180">
+          <template #default="scope">
+            <span>{{ scope.row.auditTime || '-' }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-section">
+        <span>Total {{ tableData.length }}</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          :page-size="10"
+          :page-sizes="[10, 20, 50]"
+          :total="tableData.length"
+        ></el-pagination>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'AuditList',
+  data() {
+    return {
+      activeTab: 'pending',
+      searchForm: {
+        bizId: ''
+      },
+      tableData: []
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  activated() {
+    this.loadData()
+  },
+  watch: {
+    $route(to) {
+      if (to.path === '/customization/hsbc/audit') {
+        this.loadData()
+      }
+    }
+  },
+  methods: {
+    loadData() {
+      const params = new URLSearchParams()
+      params.append('auditStatus', this.activeTab)
+      if (this.searchForm.bizId) params.append('bizId', this.searchForm.bizId)
+      request.get('/api/audit/list?' + params.toString()).then(res => {
+        this.tableData = res.data || []
+      }).catch(() => {})
+    },
+    handleReset() {
+      this.searchForm = { bizId: '' }
+      this.loadData()
+    },
+    auditTypeLabel(t) {
+      return { new: '新增', offshelf: '下架' }[t] || t
+    },
+    auditTypeTag(t) {
+      return { new: 'success', offshelf: 'danger' }[t] || ''
+    },
+    auditStatusLabel(s) {
+      return { pending: '待审核', approved: '已通过', rejected: '已驳回' }[s] || s
+    },
+    auditStatusTag(s) {
+      return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || ''
+    },
+    goDetail(id) {
+      this.$router.push(`/customization/hsbc/audit/${id}`)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.audit-list {
+  padding: 0;
+}
+.search-section {
+  display: flex;
+  flex-direction: column;
+}
+.search-row {
+  margin-bottom: 0;
+}
+.search-row .el-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0;
+}
+.search-row .el-form-item {
+  margin-right: 0;
+  margin-bottom: 8px;
+}
+.button-group {
+  display: flex;
+  gap: 5px;
+}
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+</style>

+ 149 - 0
pc-v3/src/views/CampaignManagement.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="campaign-management">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="130px" inline>
+            <el-form-item label="Campaign ID:">
+              <el-input v-model="searchForm.id" placeholder="支持精确查询" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="Campaign 名称:">
+              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="Campaign 负责人:">
+              <el-input v-model="searchForm.owner" placeholder="关键字" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="跟进员工:">
+              <el-input v-model="searchForm.staff" placeholder="员工姓名" clearable style="width:150px" />
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" @click="openModal('create')"><el-icon><Plus /></el-icon>新建 Campaign</el-button>
+        </div>
+        </div>
+      </template>
+
+      <el-table :data="tableData" stripe border style="width:100%">
+        <el-table-column label="操作" width="200" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="openModal('edit', scope.row)">编辑</el-button>
+            <el-button type="text" size="small" @click="openModal('view', scope.row)">查看</el-button>
+            <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="Campaign ID" width="120" align="center" />
+        <el-table-column prop="name" label="Campaign 名称" width="190" />
+        <el-table-column prop="owner" label="Campaign 负责人" width="160" align="center" />
+        <el-table-column label="跟进员工" width="120" align="center">
+          <template #default="scope">
+            <span :class="{ 'empty-text': !scope.row.staff }">{{ scope.row.staff || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
+        <el-table-column prop="updateTime" label="更新时间" width="180" align="center" />
+      </el-table>
+    </el-card>
+
+    <!-- 新建/编辑/查看弹窗 -->
+    <el-dialog :title="modalTitle" v-model="modalVisible" width="520px" :close-on-click-modal="false">
+      <el-form label-width="130px">
+        <el-form-item v-if="modalMode !== 'create'" label="Campaign ID">
+          <el-input :value="form.id" disabled />
+        </el-form-item>
+        <el-form-item label="Campaign 名称" required>
+          <el-input v-model="form.name" placeholder="请输入Campaign 名称" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="Campaign 负责人" required>
+          <el-input v-model="form.owner" placeholder="请输入Campaign 负责人" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="跟进员工">
+          <el-input v-model="form.staff" placeholder="请输入跟进员工姓名" :disabled="isViewMode" />
+        </el-form-item>
+      </el-form>
+      <template v-if="!isViewMode" #footer>
+        <span>
+          <el-button @click="modalVisible = false">取消</el-button>
+          <el-button type="primary" @click="submitModal">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'CampaignManagement',
+  data() {
+    return {
+      searchForm: { id: '', name: '', owner: '', staff: '' },
+      tableData: [],
+      modalVisible: false,
+      modalMode: 'create',
+      form: { id: '', name: '', owner: '', staff: '' }
+    }
+  },
+  computed: {
+    modalTitle() {
+      return this.modalMode === 'create' ? '新建 Campaign' : this.modalMode === 'edit' ? '编辑 Campaign' : '查看 Campaign'
+    },
+    isViewMode() {
+      return this.modalMode === 'view'
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      const res = await request.get('/api/campaign/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    handleSearch() { this.fetchList() },
+    handleReset() {
+      this.searchForm = { id: '', name: '', owner: '', staff: '' }
+      this.fetchList()
+    },
+    async handleDelete(row) {
+      await ElMessageBox.confirm('您确定要删除此条数据吗?删除后将无法恢复。', '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' })
+      await request.post('/api/campaign/delete', { id: row.id })
+      ElMessage.success('删除成功')
+      this.fetchList()
+    },
+    openModal(mode, row) {
+      this.modalMode = mode
+      if (mode === 'create') {
+        this.form = { id: '', name: '', owner: '', staff: '' }
+      } else {
+        this.form = { ...row }
+      }
+      this.modalVisible = true
+    },
+    async submitModal() {
+      if (!this.form.name || !this.form.owner) {
+        ElMessage.warning('请填写Campaign 名称和负责人')
+        return
+      }
+      await request.post('/api/campaign/save', this.form)
+      ElMessage.success(this.modalMode === 'create' ? '新建成功' : '保存成功')
+      this.modalVisible = false
+      this.fetchList()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section .search-row { margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.button-group { display: inline-block; margin-left: 20px; }
+.empty-text { color: #c0c4cc; }
+</style>

+ 234 - 0
pc-v3/src/views/CashDiscountGrant.vue

@@ -0,0 +1,234 @@
+<template>
+  <div class="cash-discount-grant">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <el-form :model="searchForm" label-width="100px">
+          <el-row :gutter="16">
+            <el-col :span="8">
+              <el-form-item label="客户号:">
+                <el-input v-model="searchForm.customerId" placeholder="请输入客户号" clearable></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="立减金类型:">
+                <el-select v-model="searchForm.discountType" placeholder="请选择立减金类型" clearable style="width: 100%;">
+                  <el-option v-for="item in discountTypeOptions" :key="item" :label="item" :value="item"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="发放时间:">
+                <el-date-picker
+                  v-model="searchForm.grantDateRange"
+                  type="daterange"
+                  range-separator="至"
+                  start-placeholder="开始日期"
+                  end-placeholder="结束日期"
+                  value-format="yyyy-MM-dd"
+                  style="width: 100%;"
+                ></el-date-picker>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="8">
+              <el-form-item label="是否领取:">
+                <el-select v-model="searchForm.claimed" placeholder="请选择" clearable style="width: 100%;">
+                  <el-option label="是" value="是"></el-option>
+                  <el-option label="否" value="否"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="是否使用:">
+                <el-select v-model="searchForm.used" placeholder="请选择" clearable style="width: 100%;">
+                  <el-option label="是" value="是"></el-option>
+                  <el-option label="否" value="否"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8" class="action-col">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+              <el-button type="success" @click="handleExport"><el-icon><Download /></el-icon>导出</el-button>
+            </el-col>
+          </el-row>
+        </el-form>
+        </div>
+      </template>
+
+      <el-table :data="pagedData" stripe border style="width: 100%;" v-loading="loading">
+        <el-table-column prop="grantTime" label="发放时间" width="170"></el-table-column>
+        <el-table-column prop="customerId" label="客户号" width="160"></el-table-column>
+        <el-table-column prop="discountType" label="立减金类型" width="130"></el-table-column>
+        <el-table-column prop="binRestriction" label="是否有bin卡限制" width="140">
+          <template #default="scope">
+            <el-tag :type="scope.row.binRestriction === '是' ? 'warning' : 'info'" size="small">{{ scope.row.binRestriction }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="grantAmount" label="发放金额" width="110">
+          <template #default="scope">
+            <span>¥{{ scope.row.grantAmount }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="grantReason" label="发放事由" min-width="160" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="claimed" label="是否领取" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.claimed === '是' ? 'success' : 'danger'" size="small">{{ scope.row.claimed }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="claimTime" label="领取时间" width="160"></el-table-column>
+        <el-table-column prop="validPeriod" label="使用有效期" width="220"></el-table-column>
+        <el-table-column prop="used" label="是否使用" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.used === '是' ? 'success' : 'danger'" size="small">{{ scope.row.used }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="useTime" label="使用时间" width="160"></el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 20px; text-align: right;"
+        @current-change="handlePageChange"
+        :current-page="currentPage"
+        :page-size="pageSize"
+        layout="total, prev, pager, next, jumper"
+        :total="filteredData.length"
+      ></el-pagination>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+
+const DISCOUNT_TYPES = ['借记卡', '信用卡']
+const GRANT_REASONS = ['新开户礼遇', '月度消费达标奖励', '生日专属福利', '双十一营销活动', '服务补偿', '升级Premier礼遇', '推荐好友奖励', '季度活跃奖励']
+
+function pad(n) { return String(n).padStart(2, '0') }
+
+function randomDateTime(baseDate, offsetDays) {
+  const d = new Date(baseDate)
+  d.setDate(d.getDate() - Math.floor(Math.random() * offsetDays))
+  d.setHours(Math.floor(Math.random() * 14 + 8), Math.floor(Math.random() * 60), Math.floor(Math.random() * 60))
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+function generateMockData() {
+  const list = []
+  const base = new Date(2026, 4, 15)
+  const amounts = [10, 20, 30, 50, 88, 100, 128, 200]
+
+  for (let i = 1; i <= 65; i++) {
+    const grantTime = randomDateTime(base, 90)
+    const grantDate = grantTime.slice(0, 10)
+    const validEnd = new Date(grantDate)
+    validEnd.setDate(validEnd.getDate() + 30 + Math.floor(Math.random() * 60))
+    const validEndStr = `${validEnd.getFullYear()}-${pad(validEnd.getMonth() + 1)}-${pad(validEnd.getDate())}`
+
+    const claimed = Math.random() > 0.25 ? '是' : '否'
+    const used = claimed === '是' && Math.random() > 0.35 ? '是' : '否'
+
+    list.push({
+      id: i,
+      customerId: 'CUST' + String(100000 + Math.floor(Math.random() * 900000)),
+      discountType: DISCOUNT_TYPES[Math.floor(Math.random() * DISCOUNT_TYPES.length)],
+      binRestriction: Math.random() > 0.5 ? '是' : '否',
+      grantTime: grantTime,
+      grantDate: grantDate,
+      grantAmount: amounts[Math.floor(Math.random() * amounts.length)],
+      grantReason: GRANT_REASONS[Math.floor(Math.random() * GRANT_REASONS.length)],
+      validPeriod: claimed === '是' ? `${grantDate} 至 ${validEndStr}` : '-',
+      claimed: claimed,
+      claimTime: claimed === '是' ? randomDateTime(base, 60) : '-',
+      used: used,
+      useTime: used === '是' ? randomDateTime(base, 30) : '-'
+    })
+  }
+  list.sort((a, b) => b.grantTime.localeCompare(a.grantTime))
+  return list
+}
+
+export default {
+  name: 'CashDiscountGrant',
+  data() {
+    return {
+      searchForm: {
+        customerId: '',
+        discountType: '',
+        grantDateRange: [],
+        claimed: '',
+        used: ''
+      },
+      loading: false,
+      currentPage: 1,
+      pageSize: 10,
+      tableData: generateMockData(),
+      discountTypeOptions: DISCOUNT_TYPES
+    }
+  },
+  computed: {
+    filteredData() {
+      let list = this.tableData
+      const customerId = this.searchForm.customerId.trim()
+      if (customerId) {
+        list = list.filter(item => item.customerId.includes(customerId))
+      }
+      if (this.searchForm.discountType) {
+        list = list.filter(item => item.discountType === this.searchForm.discountType)
+      }
+      if (this.searchForm.grantDateRange && this.searchForm.grantDateRange.length === 2) {
+        const [start, end] = this.searchForm.grantDateRange
+        list = list.filter(item => item.grantDate >= start && item.grantDate <= end)
+      }
+      if (this.searchForm.claimed) {
+        list = list.filter(item => item.claimed === this.searchForm.claimed)
+      }
+      if (this.searchForm.used) {
+        list = list.filter(item => item.used === this.searchForm.used)
+      }
+      return list
+    },
+    pagedData() {
+      const start = (this.currentPage - 1) * this.pageSize
+      return this.filteredData.slice(start, start + this.pageSize)
+    }
+  },
+  methods: {
+    handleSearch() {
+      this.currentPage = 1
+    },
+    handleReset() {
+      this.searchForm = {
+        customerId: '',
+        discountType: '',
+        grantDateRange: [],
+        claimed: '',
+        used: ''
+      }
+      this.currentPage = 1
+    },
+    handlePageChange(page) {
+      this.currentPage = page
+    },
+    handleExport() {
+      ElMessage.success('导出任务已提交,请稍后在下载中心查看')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.action-col {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+</style>

+ 319 - 0
pc-v3/src/views/ChannelManagement.vue

@@ -0,0 +1,319 @@
+<template>
+  <div class="channel-management">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <el-form :model="searchForm" label-width="120px" inline class="search-form">
+          <div class="search-row">
+            <el-form-item label="Channel ID:">
+              <el-input v-model="searchForm.id" placeholder="支持精确查询" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label="Channel 类型:">
+              <el-select v-model="searchForm.type" clearable placeholder="全部" style="width:160px">
+                <el-option v-for="t in typeOptions" :key="t" :label="t" :value="t" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="Channel 类别:">
+              <el-input v-model="searchForm.category" placeholder="关键字" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label="Channel 名称:">
+              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:160px" />
+            </el-form-item>
+          </div>
+          <div class="search-row">
+            <el-form-item label="Channel 子名称:">
+              <el-input v-model="searchForm.subName" placeholder="关键字" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label="跟进员工:">
+              <el-input v-model="searchForm.owner" placeholder="员工姓名" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label=" ">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </el-form-item>
+          </div>
+        </el-form>
+        <div class="action-row">
+          <el-button type="primary" @click="openModal('create')"><el-icon><Plus /></el-icon>新建 Channel</el-button>
+        </div>
+        </div>
+      </template>
+
+      <el-table :data="tableData" stripe border style="width:100%">
+        <el-table-column label="操作" width="200" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="openModal('edit', scope.row)">编辑</el-button>
+            <el-button type="text" size="small" @click="openModal('view', scope.row)">查看</el-button>
+            <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="Channel ID" width="120" align="center" />
+        <el-table-column prop="type" label="类型" width="150" align="center" />
+        <el-table-column prop="category" label="类别" width="190" />
+        <el-table-column prop="name" label="名称" width="190" />
+        <el-table-column label="子名称" width="160">
+          <template #default="scope">
+            <span :class="{ 'empty-text': !scope.row.subName }">{{ scope.row.subName || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="跟进员工" width="120" align="center">
+          <template #default="scope">
+            <span :class="{ 'empty-text': !scope.row.owner }">{{ scope.row.owner || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
+        <el-table-column prop="updateTime" label="更新时间" width="180" align="center" />
+      </el-table>
+    </el-card>
+
+    <!-- 新建/编辑/查看弹窗 -->
+    <el-dialog :title="modalTitle" v-model="modalVisible" width="560px" :close-on-click-modal="false">
+      <el-form label-width="130px">
+        <el-form-item v-if="modalMode !== 'create'" label="Channel ID">
+          <el-input :value="form.id" disabled />
+        </el-form-item>
+
+        <!-- Channel 类型 -->
+        <el-form-item label="Channel 类型" required>
+          <div class="inline-field">
+            <el-select v-model="form.type" placeholder="请选择类型" :disabled="isViewMode" @change="onTypeChange" style="flex:1">
+              <el-option v-for="t in typeOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('type', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('type', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'type'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的类型'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('type')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <!-- Channel 类别 -->
+        <el-form-item label="Channel 类别" required>
+          <div class="inline-field">
+            <el-select v-model="form.category" placeholder="请先选择类型" :disabled="isViewMode || !form.type" @change="onCategoryChange" style="flex:1">
+              <el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('category', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('category', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'category'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的类别'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('category')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <!-- Channel 名称 -->
+        <el-form-item label="Channel 名称" required>
+          <div class="inline-field">
+            <el-select v-model="form.name" placeholder="请先选择类别" :disabled="isViewMode || !form.category" style="flex:1">
+              <el-option v-for="n in nameOptions" :key="n" :label="n" :value="n" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('name', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('name', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'name'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的名称'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('name')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="Channel 子名称">
+          <el-input v-model="form.subName" placeholder="请输入Channel 子名称" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="跟进员工">
+          <el-input v-model="form.owner" placeholder="请输入跟进员工姓名" :disabled="isViewMode" />
+        </el-form-item>
+      </el-form>
+      <template v-if="!isViewMode" #footer>
+        <span>
+          <el-button @click="modalVisible = false">取消</el-button>
+          <el-button type="primary" @click="submitModal">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'ChannelManagement',
+  data() {
+    return {
+      searchForm: { id: '', type: '', category: '', name: '', subName: '', owner: '' },
+      tableData: [],
+      channelDict: {},
+      modalVisible: false,
+      modalMode: 'create',
+      form: { id: '', type: '', category: '', name: '', subName: '', owner: '' },
+      activePanel: '',
+      panelMode: 'add',
+      panelInput: ''
+    }
+  },
+  computed: {
+    modalTitle() {
+      return this.modalMode === 'create' ? '新建 Channel' : this.modalMode === 'edit' ? '编辑 Channel' : '查看 Channel'
+    },
+    isViewMode() {
+      return this.modalMode === 'view'
+    },
+    typeOptions() {
+      return Object.keys(this.channelDict)
+    },
+    categoryOptions() {
+      if (!this.form.type || !this.channelDict[this.form.type]) return []
+      return Object.keys(this.channelDict[this.form.type])
+    },
+    nameOptions() {
+      if (!this.form.type || !this.form.category) return []
+      return (this.channelDict[this.form.type] || {})[this.form.category] || []
+    }
+  },
+  created() {
+    this.fetchDict()
+    this.fetchList()
+  },
+  methods: {
+    async fetchDict() {
+      const res = await request.get('/api/channel/dict')
+      this.channelDict = res.data
+    },
+    async fetchList() {
+      const res = await request.get('/api/channel/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    handleSearch() { this.fetchList() },
+    handleReset() {
+      this.searchForm = { id: '', type: '', category: '', name: '', subName: '', owner: '' }
+      this.fetchList()
+    },
+    async handleDelete(row) {
+      await ElMessageBox.confirm('您确定要删除此条数据吗?删除后将无法恢复。', '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' })
+      await request.post('/api/channel/delete', { id: row.id })
+      ElMessage.success('删除成功')
+      this.fetchList()
+    },
+    openModal(mode, row) {
+      this.modalMode = mode
+      this.activePanel = ''
+      this.panelInput = ''
+      if (mode === 'create') {
+        this.form = { id: '', type: '', category: '', name: '', subName: '', owner: '' }
+      } else {
+        this.form = { ...row }
+      }
+      this.modalVisible = true
+    },
+    onTypeChange() {
+      this.form.category = ''
+      this.form.name = ''
+    },
+    onCategoryChange() {
+      this.form.name = ''
+    },
+    openPanel(target, mode) {
+      if (target === 'category' && !this.form.type) {
+        ElMessage.warning('请先选择 Channel 类型')
+        return
+      }
+      if (target === 'name' && (!this.form.type || !this.form.category)) {
+        ElMessage.warning('请先选择 Channel 类型和类别')
+        return
+      }
+      if (mode === 'edit') {
+        if (target === 'type' && !this.form.type) { ElMessage.warning('请先选择要编辑的类型'); return }
+        if (target === 'category' && !this.form.category) { ElMessage.warning('请先选择要编辑的类别'); return }
+        if (target === 'name' && !this.form.name) { ElMessage.warning('请先选择要编辑的名称'); return }
+      }
+      this.activePanel = target
+      this.panelMode = mode
+      this.panelInput = mode === 'edit'
+        ? (target === 'type' ? this.form.type : target === 'category' ? this.form.category : this.form.name)
+        : ''
+    },
+    async savePanel(target) {
+      const value = this.panelInput.trim()
+      if (!value) { ElMessage.warning('请输入内容'); return }
+      const oldValue = target === 'type' ? this.form.type : target === 'category' ? this.form.category : this.form.name
+      const existingValues = this.getPanelExistingValues(target)
+      const exists = existingValues.some(item => item === value)
+      if (this.panelMode === 'add' && exists) {
+        ElMessage.warning(`${this.getPanelTargetName(target)}已存在,不能重复新增`)
+        return
+      }
+      if (this.panelMode === 'edit' && value !== oldValue && exists) {
+        ElMessage.warning(`${this.getPanelTargetName(target)}已存在,不能修改为重复值`)
+        return
+      }
+      const res = await request.post('/api/channel/dict/save', {
+        target,
+        type: this.form.type,
+        category: this.form.category,
+        oldValue,
+        newValue: value,
+        mode: this.panelMode
+      })
+      this.channelDict = res.data
+      if (this.panelMode === 'add') {
+        if (target === 'type') this.form.type = value
+        else if (target === 'category') this.form.category = value
+        else this.form.name = value
+      } else {
+        if (target === 'type') this.form.type = value
+        else if (target === 'category') this.form.category = value
+        else this.form.name = value
+      }
+      this.activePanel = ''
+      ElMessage.success(this.panelMode === 'add' ? '新增成功' : '编辑成功')
+      this.fetchList()
+    },
+    getPanelExistingValues(target) {
+      if (target === 'type') return Object.keys(this.channelDict)
+      if (target === 'category') return Object.keys(this.channelDict[this.form.type] || {})
+      return ((this.channelDict[this.form.type] || {})[this.form.category] || [])
+    },
+    getPanelTargetName(target) {
+      if (target === 'type') return 'Channel 类型'
+      if (target === 'category') return 'Channel 类别'
+      return 'Channel 名称'
+    },
+    async submitModal() {
+      if (!this.form.type || !this.form.category || !this.form.name) {
+        ElMessage.warning('请完整选择类型、类别和名称')
+        return
+      }
+      await request.post('/api/channel/save', this.form)
+      ElMessage.success(this.modalMode === 'create' ? '新建成功' : '保存成功')
+      this.modalVisible = false
+      this.fetchList()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-form .search-row { display: flex; flex-wrap: wrap; margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.empty-text { color: #c0c4cc; }
+
+.inline-field { display: flex; gap: 8px; align-items: center; width: 100%; }
+.inline-field :deep(.el-select) { flex: 1; min-width: 0; }
+.quick-panel {
+  display: flex; gap: 8px; align-items: center;
+  margin-top: 8px; padding: 10px;
+  background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 4px;
+}
+</style>

+ 355 - 0
pc-v3/src/views/CreditCardEquity.vue

@@ -0,0 +1,355 @@
+<template>
+  <div class="credit-card-equity">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="80px" inline>
+            <el-form-item label="权益名称:">
+              <el-input v-model="searchForm.equityName" placeholder="请输入" style="width: 150px"></el-input>
+            </el-form-item>
+            <div class="button-group" style="margin-left: 20px;">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        </div>
+      </template>
+
+      <el-table :data="filteredTableData" stripe style="width: 100%">
+        <el-table-column label="操作" width="280" fixed="left">
+          <template #default="scope">
+            <div class="row-actions">
+              <el-popover
+                placement="bottom"
+                trigger="click"
+                width="240"
+                @show="onAdjustOrderShow(scope.$index)"
+                :ref="`adjustPopover-${scope.$index}`"
+              >
+                <div class="adjust-order-form">
+                  <div class="adjust-order-title">调整显示顺序</div>
+                  <div class="adjust-order-tip">移动端权益首页和列表页按照该顺序展示</div>
+                  <div class="adjust-order-input">
+                    <span>调整到第</span>
+                    <el-input-number
+                      v-model.number="adjustOrderPosition"
+                      :min="1"
+                      :max="tableData.length"
+                      size="small"
+                      :controls="false"
+                      style="width: 70px;margin: 0 6px;"
+                    />
+                    <span>位</span>
+                  </div>
+                  <div class="adjust-order-actions">
+                    <el-button size="small" @click="closeAdjustOrder(scope.$index)">取消</el-button>
+                    <el-button size="small" type="primary" @click="confirmAdjustOrder(scope.$index)">确定</el-button>
+                  </div>
+                </div>
+                <template #reference>
+                  <el-button type="text" size="small">调整显示顺序</el-button>
+                </template>
+              </el-popover>
+              <el-button type="text" size="small" @click="openShowPagesDialog(scope.row)">设置显示页面</el-button>
+              <el-popover
+                placement="bottom"
+                trigger="click"
+                :ref="`morePopover-${scope.$index}`"
+              >
+                <div class="more-actions-menu">
+                  <div
+                    class="more-actions-item"
+                    @click="openLinkDialogFromMore(scope.row, scope.$index)"
+                  >生成链接</div>
+                </div>
+                <template #reference>
+                  <el-button type="text" size="small"><el-icon><More /></el-icon></el-button>
+                </template>
+              </el-popover>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="equityName" label="权益名称" min-width="220" show-overflow-tooltip></el-table-column>
+        <el-table-column label="显示页面" min-width="240">
+          <template #default="scope">
+            <template v-if="visiblePages(scope.row).length">
+              <el-tag
+                v-for="page in visiblePages(scope.row)"
+                :key="page"
+                size="small"
+                style="margin-right: 4px;"
+              >{{ page }}</el-tag>
+            </template>
+            <span v-else class="empty-text">-</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-section">
+        <span>Total {{ filteredTableData.length }}</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          :page-size="10"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredTableData.length"
+        ></el-pagination>
+      </div>
+    </el-card>
+
+    <show-pages-dialog
+      v-model:visible="showPagesDialogVisible"
+      :row="showPagesRow"
+      @confirm="onShowPagesConfirm"
+    ></show-pages-dialog>
+
+    <equity-link-dialog
+      v-model:visible="linkDialogVisible"
+      :id="linkTargetId"
+      path-name="equity/credit"
+      title="生成信用卡权益链接"
+    ></equity-link-dialog>
+  </div>
+</template>
+
+<script>
+import ShowPagesDialog from '@/components/ShowPagesDialog.vue'
+import EquityLinkDialog from '@/components/EquityLinkDialog.vue'
+import { ElMessage } from 'element-plus'
+
+export default {
+  name: 'CreditCardEquity',
+  components: { ShowPagesDialog, EquityLinkDialog },
+  data() {
+    return {
+      searchForm: {
+        equityName: ''
+      },
+      showPagesDialogVisible: false,
+      showPagesRow: null,
+      linkDialogVisible: false,
+      linkTargetId: '',
+      adjustOrderPosition: 1,
+      tableData: [
+        {
+          equityName: '白金卡机场贵宾厅',
+          productId: 'CC001',
+          skuNo: 'CCSKU001',
+          merchantName: '环亚机场贵宾',
+          createTime: '2026-01-10 09:32:15',
+          showOnHome: '显示',
+          showOnAssembly: '不显示',
+          assemblyPages: []
+        },
+        {
+          equityName: '钻石卡高尔夫体验',
+          productId: 'CC002',
+          skuNo: 'CCSKU002',
+          merchantName: '尊享高尔夫俱乐部',
+          createTime: '2026-01-15 14:08:42',
+          showOnHome: '显示',
+          showOnAssembly: '不显示',
+          assemblyPages: []
+        },
+        {
+          equityName: '金卡洗车服务',
+          productId: 'CC003',
+          skuNo: 'CCSKU003',
+          merchantName: '车之家精洗服务',
+          createTime: '2026-02-01 11:20:05',
+          showOnHome: '显示',
+          showOnAssembly: '不显示',
+          assemblyPages: []
+        }
+      ]
+    }
+  },
+  computed: {
+    visiblePages() {
+      return row => {
+        const pages = []
+        if (row.showOnHome === '显示') pages.push("权益首页/列表页")
+        if (row.showOnAssembly === '显示' && Array.isArray(row.assemblyPages)) {
+          pages.push(...row.assemblyPages)
+        }
+        return pages
+      }
+    },
+    filteredTableData() {
+      return this.tableData.filter(item => {
+        if (this.searchForm.equityName && !item.equityName.includes(this.searchForm.equityName)) return false
+        return true
+      })
+    }
+  },
+  methods: {
+    handleSearch() {
+      ElMessage.success('已搜索')
+    },
+    handleReset() {
+      this.searchForm = {
+        equityName: ''
+      }
+    },
+    openShowPagesDialog(row) {
+      this.showPagesRow = row
+      this.showPagesDialogVisible = true
+    },
+    showLinkDialog(row) {
+      this.linkTargetId = row.productId
+      this.linkDialogVisible = true
+    },
+    openLinkDialogFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) {
+        moreRef.hide()
+      }
+      this.showLinkDialog(row)
+    },
+    onShowPagesConfirm(values) {
+      if (this.showPagesRow) {
+        this.showPagesRow.showOnHome = values.showOnHome
+        this.showPagesRow.showOnAssembly = values.showOnAssembly
+        this.showPagesRow.assemblyPages = [...values.assemblyPages]
+        ElMessage.success('显示页面已更新')
+      }
+    },
+    getPopoverInstance(name) {
+      const ref = this.$refs[name]
+      if (!ref) return null
+      return Array.isArray(ref) ? ref[0] : ref
+    },
+    onAdjustOrderShow(index) {
+      this.adjustOrderPosition = index + 1
+    },
+    closeAdjustOrder(index) {
+      const adjustRef = this.getPopoverInstance(`adjustPopover-${index}`)
+      if (adjustRef) {
+        adjustRef.hide()
+      }
+    },
+    confirmAdjustOrder(index) {
+      const target = Number(this.adjustOrderPosition)
+      if (!target || target < 1 || target > this.tableData.length) {
+        ElMessage.warning('请输入有效的顺序位置')
+        return
+      }
+      const targetIndex = target - 1
+      if (targetIndex !== index) {
+        const moved = this.tableData.splice(index, 1)[0]
+        this.tableData.splice(targetIndex, 0, moved)
+      }
+      ElMessage.success('顺序已调整')
+      this.closeAdjustOrder(index)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.credit-card-equity {
+  padding: 0;
+}
+
+.search-section {
+  display: flex;
+  flex-direction: column;
+}
+
+.search-row {
+  margin-bottom: 15px;
+}
+
+.search-row .el-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0;
+}
+
+.search-row .el-form-item {
+  margin-right: 0;
+  margin-bottom: 0;
+}
+
+.button-group {
+  display: flex;
+  gap: 5px;
+}
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+
+.row-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.empty-text {
+  color: #c0c4cc;
+}
+
+.row-actions .el-button + .el-button {
+  margin-left: 0;
+}
+</style>
+
+<style>
+.more-actions-menu {
+  display: flex;
+  flex-direction: column;
+  margin: -12px;
+}
+
+.more-actions-item {
+  padding: 8px 16px;
+  cursor: pointer;
+  font-size: 14px;
+  color: #606266;
+}
+
+.more-actions-item:hover {
+  background: #f5f7fa;
+  color: #409eff;
+}
+
+.adjust-order-form {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.adjust-order-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.adjust-order-tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+  padding: 8px 10px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+.adjust-order-input {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  color: #606266;
+}
+
+.adjust-order-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
+</style>

+ 279 - 0
pc-v3/src/views/CrowdNameListEdit.vue

@@ -0,0 +1,279 @@
+<template>
+  <div class="crowd-name-list-edit">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">{{ pageTitle }}</span>
+        </div>
+      </template>
+
+      <el-steps v-if="!isEdit" :active="step" finish-status="success" align-center class="steps">
+        <el-step title="基本信息">
+          <template #icon><el-icon><EditPen /></el-icon></template>
+        </el-step>
+        <el-step title="初始人员">
+          <template #icon><el-icon><User /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- Step 1: 基本信息 -->
+      <div v-show="step === 0" class="section">
+        <div class="section-title">基本信息</div>
+        <el-form
+          ref="baseForm"
+          :model="form"
+          :rules="rules"
+          label-width="120px"
+          style="max-width: 1000px; margin: 0 auto"
+        >
+          <el-row :gutter="32">
+            <el-col :span="12">
+              <el-form-item label="客群名称" prop="name">
+                <el-input v-model="form.name" placeholder="请输入" maxlength="40" show-word-limit />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="客群编号">
+                <el-input v-model="form.id" disabled>
+                  <template #suffix><el-icon><CircleClose /></el-icon></template>
+                </el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="24">
+              <el-form-item label="客群描述" prop="description">
+                <el-input
+                  v-model="form.description"
+                  type="textarea"
+                  :rows="4"
+                  placeholder="请输入"
+                  maxlength="200"
+                  show-word-limit
+                />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </div>
+
+      <!-- Step 2: 初始人员(仅新增可见) -->
+      <div v-show="step === 1 && !isEdit" class="section">
+        <div class="section-title">初始人员</div>
+        <div class="hint">至少添加 1 个客户号;可选择手动输入或上传文件。</div>
+
+        <div class="mode-switch">
+          <el-radio-group v-model="inputMode">
+            <el-radio-button label="manual">手动输入</el-radio-button>
+            <el-radio-button label="file">上传文件</el-radio-button>
+          </el-radio-group>
+        </div>
+
+        <div class="mode-content">
+          <el-input
+            v-if="inputMode === 'manual'"
+            v-model="form.manualText"
+            type="textarea"
+            :rows="8"
+            :placeholder="manualPlaceholder"
+          />
+
+          <el-upload
+            v-else
+            drag
+            action="#"
+            :auto-upload="false"
+            :show-file-list="false"
+            :on-change="handleFile"
+            accept=".txt,.csv"
+          >
+            <el-icon class="el-icon--upload"><Upload /></el-icon>
+            <div class="el-upload__text">将文件拖到此处,或<em>点击选择文件</em></div>
+            <template #tip>
+              <div class="el-upload__tip">
+                仅支持 .txt / .csv,每行一个客户号,UTF-8 编码
+                <span v-if="form.fileName" class="file-name">已选择:{{ form.fileName }}</span>
+              </div>
+            </template>
+          </el-upload>
+
+          <div class="member-stats" v-if="parsedMembers.length > 0">
+            已识别 <b>{{ parsedMembers.length }}</b> 个有效客户号
+          </div>
+        </div>
+      </div>
+
+      <div class="footer">
+        <el-button v-if="step === 0" @click="handleCancel">取消</el-button>
+        <el-button v-if="step === 1" @click="step = 0">上一步</el-button>
+        <el-button v-if="!isEdit && step === 0" type="primary" @click="handleNext">下一步</el-button>
+        <el-button v-if="isEdit && step === 0" type="primary" @click="handleSave">保存</el-button>
+        <el-button v-if="step === 1" type="primary" @click="handleSave">完成</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import crowdNameListStore from '@/store/crowdNameListStore'
+import { ElMessage } from 'element-plus'
+
+const emptyForm = () => ({
+  id: '',
+  name: '',
+  description: '',
+  manualText: '',
+  fileMembers: [],
+  fileName: ''
+})
+
+export default {
+  name: 'CrowdNameListEdit',
+  data() {
+    return {
+      step: 0,
+      inputMode: 'manual',
+      form: emptyForm(),
+      current: null,
+      rules: {
+        name: [{ required: true, message: '请输入客群名称', trigger: 'blur' }],
+        description: [{ required: true, message: '请输入客群描述', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    isEdit() {
+      return !!this.$route.params.id
+    },
+    pageTitle() {
+      return this.isEdit ? '编辑客群' : '新增客群'
+    },
+    manualPlaceholder() {
+      return '示例:\n00001\n00002\n00003'
+    },
+    parsedMembers() {
+      if (this.inputMode === 'file') return this.form.fileMembers
+      const text = this.form.manualText || ''
+      const seen = new Set()
+      const out = []
+      text.split(/\r?\n/).forEach((l) => {
+        const v = l.trim()
+        if (v && !seen.has(v)) {
+          seen.add(v)
+          out.push(v)
+        }
+      })
+      return out
+    }
+  },
+  created() {
+    if (this.isEdit) {
+      const target = crowdNameListStore.getById(this.$route.params.id)
+      if (!target) {
+        ElMessage.error('客群不存在')
+        this.$router.replace('/customization/hsbc/crowd-tasks')
+        return
+      }
+      if (target.status === 'stopped') {
+        ElMessage.warning('已停止名单不可编辑')
+        this.$router.replace('/customization/hsbc/crowd-tasks')
+        return
+      }
+      this.current = target
+      this.form = {
+        id: target.id,
+        name: target.name,
+        description: target.description,
+        manualText: '',
+        fileMembers: [],
+        fileName: ''
+      }
+    } else {
+      this.form.id = '保存后系统自动生成'
+    }
+  },
+  methods: {
+    handleNext() {
+      this.$refs.baseForm.validate((ok) => {
+        if (ok) this.step = 1
+      })
+    },
+    handleFile(file) {
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        const text = e.target.result || ''
+        const seen = new Set()
+        const out = []
+        text.split(/\r?\n/).forEach((l) => {
+          const v = l.trim()
+          if (v && !seen.has(v)) {
+            seen.add(v)
+            out.push(v)
+          }
+        })
+        this.form.fileMembers = out
+        this.form.fileName = file.name
+        if (out.length === 0) {
+          ElMessage.warning('文件未解析出有效客户号')
+        } else {
+          ElMessage.success(`已解析 ${out.length} 个有效客户号`)
+        }
+      }
+      reader.readAsText(file.raw, 'utf-8')
+    },
+    handleSave() {
+      if (this.isEdit) {
+        this.$refs.baseForm.validate((ok) => {
+          if (!ok) return
+          crowdNameListStore.update(this.current.id, {
+            name: this.form.name,
+            description: this.form.description
+          })
+          ElMessage.success('保存成功')
+          this.$router.push('/customization/hsbc/crowd-tasks')
+        })
+        return
+      }
+      // 新增路径
+      if (this.parsedMembers.length === 0) {
+        ElMessage.error(
+          this.inputMode === 'file' ? '请上传文件并确认包含至少 1 个有效客户号' : '请至少输入 1 个客户号'
+        )
+        return
+      }
+      crowdNameListStore.create({
+        name: this.form.name,
+        description: this.form.description,
+        members: this.parsedMembers
+      })
+      ElMessage.success(`创建成功,初始人员 ${this.parsedMembers.length} 人`)
+      this.$router.push('/customization/hsbc/crowd-tasks')
+    },
+    handleCancel() {
+      this.$router.push('/customization/hsbc/crowd-tasks')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.crowd-name-list-edit { padding: 0; }
+.header { display: flex; align-items: center; justify-content: center; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+
+.steps { margin: 16px 0 32px; }
+
+.section { margin-bottom: 24px; max-width: 1000px; margin-left: auto; margin-right: auto; }
+.section-title {
+  font-size: 14px; font-weight: 600; color: #303133;
+  background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
+  margin-bottom: 16px;
+}
+.hint { color: #909399; font-size: 12px; margin-bottom: 12px; }
+.mode-switch { margin-bottom: 16px; }
+.mode-content { max-width: 480px; }
+.mode-content >>> .el-textarea__inner { font-family: 'Menlo', 'Consolas', monospace; }
+.file-name { margin-left: 10px; color: #67C23A; }
+.member-stats { margin-top: 12px; color: #67C23A; font-size: 13px; }
+
+.footer { text-align: center; margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebeef5; }
+.footer .el-button { min-width: 100px; }
+</style>

+ 283 - 0
pc-v3/src/views/CrowdTaskEdit.vue

@@ -0,0 +1,283 @@
+<template>
+  <div class="crowd-task-edit">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">{{ pageTitle }}</span>
+        </div>
+      </template>
+
+      <el-steps :active="step" finish-status="success" align-center class="steps">
+        <el-step title="基本信息">
+          <template #icon><el-icon><EditPen /></el-icon></template>
+        </el-step>
+        <el-step title="规则组/奖励信息">
+          <template #icon><el-icon><SetUp /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- Step 1: 基本信息 -->
+      <div v-show="step === 0" class="section">
+        <el-form
+          ref="baseForm"
+          :model="form"
+          :rules="rules"
+          label-width="140px"
+          style="max-width: 1000px; margin: 0 auto"
+        >
+          <el-row :gutter="32">
+            <el-col :span="12">
+              <el-form-item label="客群名称" prop="name">
+                <el-input v-model="form.name" placeholder="请输入" maxlength="40" show-word-limit />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="客群编号" prop="id">
+                <el-input v-model="form.id" disabled placeholder="系统自动生成">
+                  <template #suffix><el-icon><CircleClose /></el-icon></template>
+                </el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="24">
+              <el-form-item label="客群描述" prop="description">
+                <el-input
+                  v-model="form.description"
+                  type="textarea"
+                  :rows="4"
+                  placeholder="请输入"
+                  maxlength="200"
+                  show-word-limit
+                />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="32">
+            <el-col :span="12">
+              <el-form-item label="客群任务开始日期" prop="startDate">
+                <el-date-picker
+                  v-model="form.startDate"
+                  type="date"
+                  placeholder="请选择日期"
+                  value-format="yyyy-MM-dd"
+                  style="width: 100%"
+                />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="客群任务结束日期" prop="endDate">
+                <el-date-picker
+                  v-model="form.endDate"
+                  type="date"
+                  placeholder="请选择日期"
+                  value-format="yyyy-MM-dd"
+                  :picker-options="endDateOptions"
+                  style="width: 100%"
+                />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </div>
+
+      <!-- Step 2: 规则组信息 -->
+      <div v-show="step === 1" class="section">
+        <div class="section-title">规则组配置</div>
+        <el-form label-width="140px" style="max-width: 1000px; margin: 0 auto 16px">
+          <el-form-item label="规则组间运算逻辑" required>
+            <el-select v-model="form.ruleGroupLogic" placeholder="请选择" style="width: 320px">
+              <el-option label="and/且" value="AND" />
+              <el-option label="or/或" value="OR" />
+            </el-select>
+          </el-form-item>
+        </el-form>
+
+        <el-table :data="form.ruleGroups" border style="width: 100%" empty-text="暂无规则组,请点击下方加号新增">
+          <el-table-column prop="id" label="规则组编号" width="170" align="center" />
+          <el-table-column prop="name" label="规则组名称" min-width="160" show-overflow-tooltip />
+          <el-table-column prop="description" label="规则组描述" min-width="200" show-overflow-tooltip />
+          <el-table-column label="规则组内逻辑" width="120" align="center">
+            <template #default="scope">{{ scope.row.logic === 'AND' ? 'and/且' : 'or/或' }}</template>
+          </el-table-column>
+          <el-table-column prop="ruleCount" label="规则个数" width="90" align="center" />
+          <el-table-column label="操作" width="90" align="center">
+            <template #default="scope">
+              <el-button
+                type="danger" circle size="small" title="删除"
+                @click="removeRuleGroup(scope.$index)"
+              ><el-icon><Delete /></el-icon></el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div class="add-row">
+          <el-button type="primary" circle @click="openAddDialog"><el-icon><Plus /></el-icon></el-button>
+        </div>
+      </div>
+
+      <div class="footer">
+        <el-button v-if="step === 0" @click="handleCancel">取消</el-button>
+        <el-button v-if="step === 1" @click="step = 0">上一步</el-button>
+        <el-button v-if="step === 0" type="primary" @click="handleNext">下一步</el-button>
+        <el-button v-if="step === 1" type="primary" @click="handleSave">完成</el-button>
+      </div>
+    </el-card>
+
+    <add-rule-group-dialog
+      v-model:visible="dialogVisible"
+      :exclude-ids="selectedRuleGroupIds"
+      @save="onRuleGroupAdded"
+    />
+  </div>
+</template>
+
+<script>
+import crowdTaskStore from '@/store/crowdTaskStore'
+import AddRuleGroupDialog from '@/components/crowdTask/AddRuleGroupDialog.vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+const emptyForm = () => ({
+  name: '',
+  id: '',
+  description: '',
+  startDate: '',
+  endDate: '',
+  ruleGroupLogic: '',
+  ruleGroups: []
+})
+
+export default {
+  name: 'CrowdTaskEdit',
+  components: { AddRuleGroupDialog },
+  data() {
+    return {
+      step: 0,
+      form: emptyForm(),
+      current: null,
+      dialogVisible: false,
+      rules: {
+        name: [{ required: true, message: '请输入客群名称', trigger: 'blur' }],
+        description: [{ required: true, message: '请输入客群描述', trigger: 'blur' }],
+        startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
+        endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }]
+      }
+    }
+  },
+  computed: {
+    isEdit() {
+      return this.$route.name === 'CrowdTaskEdit'
+    },
+    isCopy() {
+      return this.$route.name === 'CrowdTaskCopy'
+    },
+    pageTitle() {
+      if (this.isEdit) return '编辑客群任务'
+      if (this.isCopy) return '复制客群任务'
+      return '新建客群任务'
+    },
+    selectedRuleGroupIds() {
+      return this.form.ruleGroups.map((r) => r.id)
+    },
+    endDateOptions() {
+      const start = this.form.startDate
+      return {
+        disabledDate(d) {
+          if (!start) return false
+          return d.getTime() < new Date(start).getTime()
+        }
+      }
+    }
+  },
+  created() {
+    if (this.isEdit || this.isCopy) {
+      const source = crowdTaskStore.getById(this.$route.params.id)
+      if (!source) {
+        ElMessage.error('任务不存在')
+        this.$router.replace('/customization/hsbc/crowd-tasks')
+        return
+      }
+      this.current = source
+      this.form = {
+        name: this.isCopy ? source.name + '-副本' : source.name,
+        id: this.isCopy ? '复制保存后生成' : source.id,
+        description: source.description,
+        startDate: source.startDate,
+        endDate: source.endDate,
+        ruleGroupLogic: source.ruleGroupLogic,
+        ruleGroups: JSON.parse(JSON.stringify(source.ruleGroups || []))
+      }
+    } else {
+      this.form.id = '保存后系统自动生成'
+    }
+  },
+  methods: {
+    handleNext() {
+      this.$refs.baseForm.validate((ok) => {
+        if (!ok) return
+        if (this.form.startDate && this.form.endDate && this.form.endDate < this.form.startDate) {
+          ElMessage.error('结束日期不能早于开始日期')
+          return
+        }
+        this.step = 1
+      })
+    },
+    openAddDialog() {
+      this.dialogVisible = true
+    },
+    onRuleGroupAdded(rg) {
+      this.form.ruleGroups.push(rg)
+      ElMessage.success('已添加规则组')
+    },
+    removeRuleGroup(idx) {
+      ElMessageBox.confirm('确定移除该规则组吗?', '提示', {
+        confirmButtonText: '移除', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        this.form.ruleGroups.splice(idx, 1)
+      }).catch(() => {})
+    },
+    handleSave() {
+      if (!this.form.ruleGroupLogic) {
+        ElMessage.error('请选择规则组间运算逻辑')
+        return
+      }
+      if (this.form.ruleGroups.length === 0) {
+        ElMessage.error('请至少添加一条规则组')
+        return
+      }
+      const payload = { ...this.form }
+      if (this.isEdit) {
+        // 校验状态下的编辑需回退到新建并重置运行信息
+        const resetRun = this.current.status === 'verifying'
+        crowdTaskStore.update(this.current.id, payload, { resetRun })
+        ElMessage.success(resetRun ? '保存成功,任务回退至新建状态,需重新运行' : '保存成功')
+      } else {
+        crowdTaskStore.create(payload)
+        ElMessage.success(this.isCopy ? '复制成功' : '创建成功')
+      }
+      this.$router.push('/customization/hsbc/crowd-tasks')
+    },
+    handleCancel() {
+      this.$router.push('/customization/hsbc/crowd-tasks')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.crowd-task-edit { padding: 0; }
+.header { display: flex; align-items: center; justify-content: center; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+
+.steps { margin: 16px 0 32px; }
+
+.section { margin-bottom: 24px; }
+.section-title {
+  font-size: 14px; font-weight: 600; color: #303133;
+  background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
+  margin-bottom: 16px;
+}
+
+.add-row { display: flex; padding: 16px 0; }
+
+.footer { text-align: center; margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebeef5; }
+.footer .el-button { min-width: 100px; }
+</style>

+ 334 - 0
pc-v3/src/views/CrowdTaskList.vue

@@ -0,0 +1,334 @@
+<template>
+  <div class="crowd-task-list">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="90px" inline>
+            <el-form-item label="客群编号:">
+              <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="客群名称:">
+              <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="类型:">
+              <el-select v-model="searchForm.type" placeholder="全部" clearable style="width: 160px">
+                <el-option label="规则组圈选" value="rule_group" />
+                <el-option label="指定客户号" value="name_list" />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-dropdown trigger="click" @command="handleCreateByType">
+            <el-button type="primary">
+              <el-icon><Plus /></el-icon>新增任务<el-icon class="el-icon--right"><ArrowDown /></el-icon>
+            </el-button>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="rule_group">规则组圈选</el-dropdown-item>
+                <el-dropdown-item command="name_list">指定客户号</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+        </div>
+      </template>
+
+      <el-table
+        :data="pagedData"
+        stripe border
+        style="width: 100%"
+      >
+        <el-table-column prop="id" label="客群编号" width="160" />
+        <el-table-column prop="name" label="客群名称" min-width="160" show-overflow-tooltip />
+        <el-table-column label="类型" width="120" align="center">
+          <template #default="scope">
+            <el-tag :type="scope.row.type === 'rule_group' ? '' : 'warning'" size="small">
+              {{ typeLabel(scope.row.type) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="100" align="center">
+          <template #default="scope">
+            <el-tag :type="statusTagType(scope.row)" size="small">
+              {{ statusLabel(scope.row) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="人数" width="80" align="center">
+          <template #default="scope">
+            <span v-if="scope.row.type === 'name_list'">{{ (scope.row.members || []).length }}</span>
+            <span v-else class="muted">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator" label="创建人" width="100" />
+        <el-table-column label="审核人" width="100">
+          <template #default="scope">{{ scope.row.reviewer || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="任务创建时间" width="170" />
+        <el-table-column label="任务开始/结束日期" width="190" align="center">
+          <template #default="scope">
+            <span v-if="scope.row.type === 'rule_group'">
+              {{ scope.row.startDate }} / {{ scope.row.endDate }}
+            </span>
+            <span v-else class="muted">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="updatedAt" label="更新时间" width="170" />
+        <el-table-column label="操作" width="280" fixed="right" align="center">
+          <template #default="scope">
+            <!-- 规则组圈选 -->
+            <template v-if="scope.row.type === 'rule_group'">
+              <el-button
+                v-if="canDoTask(scope.row, 'edit')"
+                type="primary" circle size="small" title="编辑"
+                @click.stop="handleEditTask(scope.row)"
+              ><el-icon><Edit /></el-icon></el-button>
+              <el-button
+                v-if="canDoTask(scope.row, 'run')"
+                type="success" circle size="small" title="运行"
+                @click.stop="handleRun(scope.row)"
+              ><el-icon><VideoPlay /></el-icon></el-button>
+              <el-button
+                v-if="canDoTask(scope.row, 'publish')"
+                type="success" circle size="small" title="上线"
+                @click.stop="handlePublish(scope.row)"
+              ><el-icon><Upload /></el-icon></el-button>
+              <el-button
+                v-if="canDoTask(scope.row, 'stop')"
+                type="warning" circle size="small" title="停止"
+                @click.stop="handleStop(scope.row)"
+              ><el-icon><Close /></el-icon></el-button>
+              <el-button
+                v-if="canDoTask(scope.row, 'detail')"
+                type="info" circle size="small" title="详情"
+                @click.stop="handleDetail(scope.row)"
+              ><el-icon><View /></el-icon></el-button>
+              <el-button
+                type="primary" plain circle size="small" title="复制"
+                @click.stop="handleCopy(scope.row)"
+              ><el-icon><DocumentCopy /></el-icon></el-button>
+            </template>
+            <!-- 指定客户号 -->
+            <template v-else>
+              <el-button
+                v-if="scope.row.status === 'draft'"
+                type="success" circle size="small" title="启用"
+                @click.stop="handleActivate(scope.row)"
+              ><el-icon><Check /></el-icon></el-button>
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" circle size="small" title="编辑"
+                @click.stop="handleEditNameList(scope.row)"
+              ><el-icon><Edit /></el-icon></el-button>
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" plain circle size="small" title="人员调整"
+                @click.stop="openAdjust(scope.row)"
+              ><el-icon><User /></el-icon></el-button>
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" plain circle size="small" title="上传调整文件"
+                @click.stop="openUpload(scope.row)"
+              ><el-icon><Upload /></el-icon></el-button>
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="warning" circle size="small" title="停止"
+                @click.stop="handleStopNameList(scope.row)"
+              ><el-icon><Close /></el-icon></el-button>
+              <span v-if="scope.row.status === 'stopped'" class="muted">无可执行操作</span>
+            </template>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-section">
+        <span class="total">共 {{ filteredData.length }} 条</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          v-model:current-page="page.current"
+          v-model:page-size="page.size"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredData.length"
+        />
+      </div>
+    </el-card>
+
+    <adjust-member-dialog
+      v-model:visible="adjustVisible"
+      :default-list-id="adjustListId"
+    />
+    <upload-adjust-dialog
+      v-model:visible="uploadVisible"
+      :default-list-id="adjustListId"
+    />
+  </div>
+</template>
+
+<script>
+import crowdTaskStore from '@/store/crowdTaskStore'
+import crowdNameListStore from '@/store/crowdNameListStore'
+import AdjustMemberDialog from '@/components/crowdNameList/AdjustMemberDialog.vue'
+import UploadAdjustDialog from '@/components/crowdNameList/UploadAdjustDialog.vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+export default {
+  name: 'CrowdTaskList',
+  components: { AdjustMemberDialog, UploadAdjustDialog },
+  data() {
+    return {
+      searchForm: { id: '', name: '', type: '' },
+      page: { current: 1, size: 10 },
+      adjustVisible: false,
+      adjustListId: '',
+      uploadVisible: false
+    }
+  },
+  computed: {
+    filteredData() {
+      const { id, name, type } = this.searchForm
+      const tasks = type === 'name_list'
+        ? []
+        : crowdTaskStore.query({ id, name }).map((r) => ({ ...r, type: 'rule_group' }))
+      const lists = type === 'rule_group'
+        ? []
+        : crowdNameListStore.query({ id, name }).map((r) => ({ ...r, type: 'name_list' }))
+      const merged = [...tasks, ...lists]
+      merged.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
+      return merged
+    },
+    pagedData() {
+      const start = (this.page.current - 1) * this.page.size
+      return this.filteredData.slice(start, start + this.page.size)
+    }
+  },
+  methods: {
+    typeLabel(t) {
+      return t === 'rule_group' ? '规则组圈选' : '指定客户号'
+    },
+    statusLabel(row) {
+      if (row.type === 'name_list') {
+        return { draft: '新建', active: '已生效', stopped: '已停止' }[row.status] || row.status
+      }
+      return { draft: '新建', verifying: '校验', online: '上线', stopped: '已停止' }[row.status] || row.status
+    },
+    statusTagType(row) {
+      if (row.type === 'name_list') {
+        return { draft: 'info', active: 'success', stopped: 'danger' }[row.status] || ''
+      }
+      return { draft: 'info', verifying: 'warning', online: 'success', stopped: 'danger' }[row.status] || ''
+    },
+    canDoTask(row, action) {
+      return crowdTaskStore.allowedActions(row).includes(action)
+    },
+    handleSearch() {
+      this.page.current = 1
+    },
+    handleCreateByType(type) {
+      if (type === 'rule_group') {
+        this.$router.push('/customization/hsbc/crowd-tasks/add')
+      } else {
+        this.$router.push('/customization/hsbc/crowd-name-lists/add')
+      }
+    },
+    handleCopy(row) {
+      ElMessageBox.confirm(
+        `确定要复制任务「${row.name}」吗?将生成一条状态为「新建」的副本。`,
+        '复制任务',
+        { confirmButtonText: '复制', cancelButtonText: '取消', type: 'info' }
+      ).then(() => {
+        const copy = crowdTaskStore.copyFrom(row.id)
+        if (copy) ElMessage.success(`已复制为 ${copy.id}`)
+      }).catch(() => {})
+    },
+    openAdjust(row) {
+      this.adjustListId = row.id
+      this.adjustVisible = true
+    },
+    openUpload(row) {
+      this.adjustListId = row.id
+      this.uploadVisible = true
+    },
+    handleEditTask(row) {
+      this.$router.push(`/customization/hsbc/crowd-tasks/edit/${row.id}`)
+    },
+    handleEditNameList(row) {
+      this.$router.push(`/customization/hsbc/crowd-name-lists/edit/${row.id}`)
+    },
+    handleDetail(row) {
+      this.$router.push(`/customization/hsbc/crowd-tasks/view/${row.id}`)
+    },
+    handleRun(row) {
+      ElMessageBox.confirm(
+        '点击运行后将向行内提交规则,等待返回 count/sample 结果。',
+        '运行任务',
+        { confirmButtonText: '运行', cancelButtonText: '取消', type: 'info' }
+      ).then(() => {
+        crowdTaskStore.run(row.id)
+        ElMessage.success('已触发运行,请稍候查看详情')
+      }).catch(() => {})
+    },
+    handlePublish(row) {
+      ElMessageBox.confirm(
+        '点击上线后将运行 fullist,审核通过后任务进入上线状态。',
+        '上线任务',
+        { confirmButtonText: '上线', cancelButtonText: '取消', type: 'warning' }
+      ).then(() => {
+        crowdTaskStore.publish(row.id)
+        ElMessage.success('已提交审核并触发上线运行')
+      }).catch(() => {})
+    },
+    handleStop(row) {
+      ElMessageBox.confirm(
+        `停止后任务不可恢复,仅可查看详情。确认停止「${row.name}」?`,
+        '停止任务',
+        { confirmButtonText: '停止', cancelButtonText: '取消', type: 'warning' }
+      ).then(() => {
+        crowdTaskStore.stop(row.id)
+        ElMessage.success('已停止')
+      }).catch(() => {})
+    },
+    handleActivate(row) {
+      ElMessageBox.confirm(`启用名单「${row.name}」需通过审核后生效,确认提交?`, '启用', {
+        confirmButtonText: '提交', cancelButtonText: '取消', type: 'info'
+      }).then(() => {
+        crowdNameListStore.activate(row.id)
+        ElMessage.success('已生效(demo 默认通过审核)')
+      }).catch(() => {})
+    },
+    handleStopNameList(row) {
+      ElMessageBox.confirm(`停止后名单不可再调整,确认停止「${row.name}」?`, '停止', {
+        confirmButtonText: '停止', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        crowdNameListStore.stop(row.id)
+        ElMessage.success('已停止')
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.crowd-task-list { padding: 0; }
+
+.search-section { display: flex; flex-direction: column; }
+.search-row .el-form { display: flex; flex-wrap: wrap; gap: 8px 0; }
+.search-row .el-form-item { margin-right: 8px; margin-bottom: 8px; }
+
+.action-row { margin-top: 8px; display: flex; gap: 8px; }
+
+.muted { color: #c0c4cc; font-size: 12px; }
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+.pagination-section .total { color: #606266; font-size: 13px; }
+</style>

+ 191 - 0
pc-v3/src/views/CrowdTaskView.vue

@@ -0,0 +1,191 @@
+<template>
+  <div class="crowd-task-view">
+    <el-card v-if="current">
+      <template #header>
+        <div class="header">
+          <span class="title">客群任务详情</span>
+        </div>
+      </template>
+
+      <!-- 基本信息 -->
+      <div class="section-title">基本信息</div>
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="客群名称">{{ current.name }}</el-descriptions-item>
+        <el-descriptions-item label="客群编号">{{ current.id }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="statusTagType(current.status)" size="small">{{ statusLabel(current.status) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="客群描述" :span="2">{{ current.description || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="任务开始/结束日期" :span="2">
+          {{ current.startDate }} ~ {{ current.endDate }}
+        </el-descriptions-item>
+        <el-descriptions-item label="任务创建时间">{{ current.createdAt }}</el-descriptions-item>
+        <el-descriptions-item label="任务创建用户">{{ current.creator }}</el-descriptions-item>
+        <el-descriptions-item label="审核人">{{ current.reviewer || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="最近更新时间">{{ current.updatedAt }}</el-descriptions-item>
+      </el-descriptions>
+
+      <!-- 规则组信息 -->
+      <div class="section-title" style="margin-top: 24px">规则组信息</div>
+      <div class="rule-group-meta">
+        <span class="meta-label">规则组间逻辑:</span>
+        <span>{{ current.ruleGroupLogic === 'AND' ? 'and/且' : 'or/或' }}</span>
+      </div>
+      <div class="rule-group-meta">
+        <span class="meta-label">规则组详情:</span>
+      </div>
+      <el-table :data="current.ruleGroups || []" border style="width: 100%" empty-text="无规则组">
+        <el-table-column prop="id" label="规则组编号" width="170" align="center" />
+        <el-table-column prop="name" label="规则组名称" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="description" label="规则组描述" min-width="200" show-overflow-tooltip />
+        <el-table-column label="规则组内逻辑" width="120" align="center">
+          <template #default="scope">{{ scope.row.logic === 'AND' ? 'and/且' : 'or/或' }}</template>
+        </el-table-column>
+        <el-table-column prop="ruleCount" label="规则个数" width="90" align="center" />
+      </el-table>
+
+      <!-- 运行信息 -->
+      <div class="section-title" style="margin-top: 24px">运行信息</div>
+      <div class="run-info">
+        <div class="run-row">
+          <span class="run-label">总数(count):</span>
+          <span class="run-value">{{ countDisplay }}</span>
+        </div>
+        <div class="run-row">
+          <span class="run-label">校验(sample):</span>
+          <span class="run-value">
+            <el-link
+              v-if="sampleAvailable"
+              type="primary"
+              :underline="false"
+              @click="showSample = true"
+            >详情</el-link>
+            <span v-else-if="current.run.sample === 'running'" class="muted">详情</span>
+            <span v-else class="muted">未运行</span>
+          </span>
+          <el-button
+            size="small"
+            type="primary"
+            :disabled="!canRerun"
+            style="margin-left: 16px"
+            @click="handleRerun"
+          >重新校验</el-button>
+        </div>
+        <div class="run-row">
+          <span class="run-label">全量圈客(fulllist):</span>
+          <span :class="['run-value', fulllistClass]">{{ fulllistDisplay }}</span>
+        </div>
+      </div>
+
+      <div class="footer">
+        <el-button type="primary" @click="$router.push('/customization/hsbc/crowd-tasks')">返回</el-button>
+      </div>
+    </el-card>
+
+    <sample-detail-dialog
+      v-model:visible="showSample"
+      :rows="sampleRows"
+    />
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import crowdTaskStore from '@/store/crowdTaskStore'
+import SampleDetailDialog from '@/components/crowdTask/SampleDetailDialog.vue'
+
+export default {
+  name: 'CrowdTaskView',
+  components: { SampleDetailDialog },
+  data() {
+    return { current: null, showSample: false }
+  },
+  computed: {
+    sampleAvailable() {
+      return this.current && Array.isArray(this.current.run.sample) && this.current.run.sample.length > 0
+    },
+    sampleRows() {
+      return this.sampleAvailable ? this.current.run.sample : []
+    },
+    countDisplay() {
+      const c = this.current && this.current.run.count
+      if (c === null || c === undefined) return '未运行'
+      if (c === 'running') return '正在运行'
+      return c
+    },
+    fulllistDisplay() {
+      const f = this.current && this.current.run.fulllist
+      if (f === null || f === undefined) return '未运行'
+      if (f === 'running') return '正在运行'
+      if (f === 'completed') return '已完成'
+      return f
+    },
+    fulllistClass() {
+      const f = this.current && this.current.run.fulllist
+      if (!f) return 'red'
+      if (f === 'running') return 'muted'
+      return 'green'
+    },
+    canRerun() {
+      if (!this.current) return false
+      if (this.current.run.count === 'running' || this.current.run.sample === 'running') return false
+      if (this.current.status === 'stopped') return false
+      // 任意非"正在运行"且非"已停止"状态都支持运维人员重跑
+      return this.current.run.count !== null
+    }
+  },
+  created() {
+    const target = crowdTaskStore.getById(this.$route.params.id)
+    if (!target) {
+      ElMessage.error('任务不存在')
+      this.$router.replace('/customization/hsbc/crowd-tasks')
+      return
+    }
+    this.current = target
+  },
+  methods: {
+    statusLabel(s) {
+      return { draft: '新建', verifying: '校验', online: '上线', stopped: '已停止' }[s] || s
+    },
+    statusTagType(s) {
+      return { draft: 'info', verifying: 'warning', online: 'success', stopped: 'danger' }[s] || ''
+    },
+    handleRerun() {
+      ElMessageBox.confirm('重新校验将重新运行 count 和 sample,不影响 fullist。', '重新校验', {
+        confirmButtonText: '运行', cancelButtonText: '取消', type: 'info'
+      }).then(() => {
+        crowdTaskStore.rerunCountSample(this.current.id)
+        ElMessage.success('已触发重新校验,请稍候查看结果')
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.crowd-task-view { padding: 0; }
+.header { display: flex; align-items: center; justify-content: center; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+
+.section-title {
+  font-size: 14px; font-weight: 600; color: #303133;
+  background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
+  margin-bottom: 16px;
+}
+
+.rule-group-meta { margin: 8px 0; font-size: 13px; color: #606266; }
+.rule-group-meta .meta-label { font-weight: 600; color: #303133; }
+
+.run-info {
+  border: 1px solid #fbbfbf; border-radius: 4px;
+  background: #fffaf0; padding: 16px 24px;
+}
+.run-row { display: flex; align-items: center; padding: 6px 0; font-size: 14px; }
+.run-label { width: 160px; color: #303133; font-weight: 600; }
+.run-value { color: #606266; }
+.run-value.red { color: #F56C6C; }
+.run-value.green { color: #67C23A; }
+.muted { color: #c0c4cc; }
+
+.footer { text-align: center; margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebeef5; }
+</style>

+ 259 - 0
pc-v3/src/views/CustomerEquityGrant.vue

@@ -0,0 +1,259 @@
+<template>
+  <div class="customer-equity-grant">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+          <el-form :model="searchForm" label-width="130px">
+          <el-row :gutter="16">
+            <el-col :span="8">
+              <el-form-item label="客户号:">
+                <el-input v-model="searchForm.customerId" placeholder="请输入客户号" clearable></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="权益编号:">
+                <el-input v-model="searchForm.equityCode" placeholder="请输入权益编号" clearable></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="发放时间:">
+                <el-date-picker
+                  v-model="searchForm.grantTimeRange"
+                  type="daterange"
+                  range-separator="至"
+                  start-placeholder="开始日期"
+                  end-placeholder="结束日期"
+                  value-format="yyyy-MM-dd"
+                  style="width: 100%;"
+                ></el-date-picker>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="8">
+              <el-form-item label="Channel 名称:">
+                <el-select v-model="searchForm.channelName" placeholder="请选择 Channel 名称" clearable filterable style="width: 100%;">
+                  <el-option v-for="item in channelOptions" :key="item.id" :label="item.name" :value="item.name"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="Campaign 名称:">
+                <el-select v-model="searchForm.campaignName" placeholder="请选择 Campaign 名称" clearable filterable style="width: 100%;">
+                  <el-option v-for="item in campaignOptions" :key="item.id" :label="item.name" :value="item.name"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="客户账户类型:">
+                <el-select v-model="searchForm.accountType" placeholder="请选择客户账户类型" clearable style="width: 100%;">
+                  <el-option v-for="item in accountTypeOptions" :key="item" :label="item" :value="item"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="24" class="action-col">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+              <el-button type="success" @click="handleExport">
+                <el-icon><Download /></el-icon>导出
+              </el-button>
+            </el-col>
+          </el-row>
+        </el-form>
+        </div>
+      </template>
+
+      <el-table :data="pagedData" stripe style="width: 100%;" v-loading="loading">
+        <el-table-column prop="grantTime" label="发放时间" width="170"></el-table-column>
+        <el-table-column prop="equityName" label="权益名称"></el-table-column>
+        <el-table-column prop="equityCode" label="权益编号" width="130"></el-table-column>
+        <el-table-column prop="campaignId" label="Campaign ID" width="120"></el-table-column>
+        <el-table-column prop="campaignName" label="Campaign 名称" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="channelId" label="Channel ID" width="120"></el-table-column>
+        <el-table-column prop="channelName" label="Channel 名称"></el-table-column>
+        <el-table-column prop="customerId" label="客户号" width="140"></el-table-column>
+        <el-table-column prop="accountType" label="客户账户类型" width="130">
+          <template #default="scope">
+            <el-tag :type="accountTypeTag[scope.row.accountType]" size="small">{{ scope.row.accountType }}</el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 20px; text-align: right;"
+        @current-change="handlePageChange"
+        :current-page="currentPage"
+        :page-size="pageSize"
+        layout="total, prev, pager, next, jumper"
+        :total="filteredData.length"
+      ></el-pagination>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+
+const ACCOUNT_TYPES = ['Premier', 'Elite', 'PB', 'Prospect']
+const CHANNELS = [
+  { id: 'CH0001', name: '汇丰中国客户服务号' },
+  { id: 'CH0002', name: 'MGM - Mortgage' },
+  { id: 'CH0003', name: 'MGM - Premier' },
+  { id: 'CH0004', name: 'CCSS SMS' },
+  { id: 'CH0005', name: 'GPB Referral' },
+  { id: 'CH0006', name: 'HK referral' },
+  { id: 'CH0007', name: 'Non WPB staff Referral' },
+  { id: 'CH0008', name: 'Pinnacle Referral' },
+  { id: 'CH0009', name: 'RBB Refferal' },
+  { id: 'CH0010', name: 'New staff' },
+  { id: 'CH0011', name: 'IBC referral staff code' },
+  { id: 'CH0012', name: 'HTC' },
+  { id: 'CH0013', name: 'NIVIDA' },
+  { id: 'CH0014', name: 'Mobile X' },
+  { id: 'CH0015', name: 'PWS site' },
+  { id: 'CH0016', name: '汇丰中国小程序' },
+  { id: 'CH0017', name: '汇丰中国订阅号' },
+  { id: 'CH0018', name: '推文尾部入口' },
+  { id: 'CH0019', name: 'Chatbot' },
+  { id: 'CH0020', name: 'SMS' },
+  { id: 'CH0021', name: 'River APP' }
+]
+const EQUITY_NAMES = [
+  '机场贵宾厅', '酒店权益', '高尔夫权益', '健康体检', '保险权益',
+  '积分兑换', '消费返现', '生日礼遇', '出行权益', '餐饮优惠'
+]
+const CAMPAIGNS = [
+  { id: 'CP9001', name: 'Q3 大客户回馈' },
+  { id: 'CP9002', name: '新春开户活动' },
+  { id: 'CP9003', name: '618 消费季营销' },
+  { id: 'CP9004', name: '双十一专享权益' },
+  { id: 'CP9005', name: 'Premier 升级礼遇' }
+]
+
+function generateMockData() {
+  const list = []
+  for (let i = 1; i <= 86; i++) {
+    const d = new Date(2026, 4, 15)
+    d.setDate(d.getDate() - Math.floor(Math.random() * 60))
+    d.setHours(Math.floor(Math.random() * 24), Math.floor(Math.random() * 60), Math.floor(Math.random() * 60))
+    const pad = n => String(n).padStart(2, '0')
+    const timeStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+
+    const campaign = CAMPAIGNS[Math.floor(Math.random() * CAMPAIGNS.length)]
+    const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)]
+    list.push({
+      id: i,
+      grantTime: timeStr,
+      equityName: EQUITY_NAMES[Math.floor(Math.random() * EQUITY_NAMES.length)],
+      equityCode: 'Q' + String(202600000 + i),
+      campaignId: campaign.id,
+      campaignName: campaign.name,
+      channelId: channel.id,
+      channelName: channel.name,
+      customerId: 'CUST' + String(100000 + Math.floor(Math.random() * 900000)),
+      accountType: ACCOUNT_TYPES[Math.floor(Math.random() * ACCOUNT_TYPES.length)]
+    })
+  }
+  list.sort((a, b) => b.grantTime.localeCompare(a.grantTime))
+  return list
+}
+
+export default {
+  name: 'CustomerEquityGrant',
+  data() {
+    return {
+      searchForm: {
+        customerId: '',
+        grantTimeRange: [],
+        channelName: '',
+        campaignName: '',
+        equityCode: '',
+        accountType: ''
+      },
+      loading: false,
+      currentPage: 1,
+      pageSize: 10,
+      tableData: generateMockData(),
+      channelOptions: CHANNELS,
+      campaignOptions: CAMPAIGNS,
+      accountTypeOptions: ACCOUNT_TYPES,
+      accountTypeTag: {
+        Premier: '',
+        Elite: 'success',
+        PB: 'warning',
+        Prospect: 'info'
+      }
+    }
+  },
+  computed: {
+    filteredData() {
+      let list = this.tableData
+      const customerId = this.searchForm.customerId.trim()
+      if (customerId) {
+        list = list.filter(item => item.customerId.includes(customerId))
+      }
+      if (this.searchForm.grantTimeRange && this.searchForm.grantTimeRange.length === 2) {
+        const start = this.searchForm.grantTimeRange[0]
+        const end = this.searchForm.grantTimeRange[1] + ' 23:59:59'
+        list = list.filter(item => item.grantTime >= start && item.grantTime <= end)
+      }
+      if (this.searchForm.channelName) {
+        list = list.filter(item => item.channelName === this.searchForm.channelName)
+      }
+      if (this.searchForm.campaignName) {
+        list = list.filter(item => item.campaignName === this.searchForm.campaignName)
+      }
+      const equityCode = this.searchForm.equityCode.trim()
+      if (equityCode) {
+        list = list.filter(item => item.equityCode.includes(equityCode))
+      }
+      if (this.searchForm.accountType) {
+        list = list.filter(item => item.accountType === this.searchForm.accountType)
+      }
+      return list
+    },
+    pagedData() {
+      const start = (this.currentPage - 1) * this.pageSize
+      return this.filteredData.slice(start, start + this.pageSize)
+    }
+  },
+  methods: {
+    handleSearch() {
+      this.currentPage = 1
+    },
+    handleReset() {
+      this.searchForm = {
+        customerId: '',
+        grantTimeRange: [],
+        channelName: '',
+        campaignName: '',
+        equityCode: '',
+        accountType: ''
+      }
+      this.currentPage = 1
+    },
+    handlePageChange(page) {
+      this.currentPage = page
+    },
+    handleExport() {
+      ElMessage.success('导出任务已提交,请稍后在下载中心查看')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.action-col {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+</style>

+ 1002 - 0
pc-v3/src/views/EditEquityProduct.vue

@@ -0,0 +1,1002 @@
+<template>
+  <div class="edit-equity-product">
+    <el-card>
+      <template #header>
+        <div class="header-title">
+          <span>编辑权益</span>
+        </div>
+      </template>
+
+      <!-- 步骤条 -->
+      <el-steps :active="activeStep" simple style="margin-bottom: 20px;">
+        <el-step title="填写权益信息">
+          <template #icon><el-icon><InfoFilled /></el-icon></template>
+        </el-step>
+        <el-step title="设置规则">
+          <template #icon><el-icon><Setting /></el-icon></template>
+        </el-step>
+        <el-step title="更多设置">
+          <template #icon><el-icon><Monitor /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- 步骤内容 -->
+      <div v-show="activeStep === 0">
+        <!-- 基本信息 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">基本信息</div>
+          </template>
+        <el-form :model="form" label-width="120px" label-position="left">
+          <el-row :gutter="20">
+            <el-col :span="8">
+              <el-form-item label="权益来源" required>
+                <!-- 编辑时不允许修改权益来源 -->
+                <el-radio-group v-model="form.source" disabled>
+                  <el-radio label="银行自有"></el-radio>
+                  <el-radio label="荣数商品"></el-radio>
+                  <el-radio label="第三方"></el-radio>
+                </el-radio-group>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8" v-if="form.source === '荣数商品'">
+              <el-form-item label="选择权益" required>
+                <el-input
+                  v-model="form.rongshuProduct"
+                  placeholder="请选择权益"
+                  readonly
+                >
+                  <template #append>
+                    <el-button @click="rongshuDialogVisible = true">选择</el-button>
+                  </template>
+                </el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8" v-if="form.source === '银行自有'">
+              <el-form-item label="权益类型" required>
+                <el-radio-group v-model="form.productType">
+                  <el-radio label="券码"></el-radio>
+                </el-radio-group>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="8" v-if="form.source === '第三方'">
+              <el-form-item label="外部权益链接" required>
+                <el-select v-model="form.externalLink" placeholder="请选择外部权益链接">
+                  <el-option label="太古地产" value="太古地产"></el-option>
+                  <el-option label="龙腾" value="龙腾"></el-option>
+                  <el-option label="h5链接" value="h5链接"></el-option>
+                  <el-option label="小程序链接" value="小程序链接"></el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8" v-if="form.source === '第三方' && form.externalLink === 'h5链接'">
+              <el-form-item label="h5链接" required>
+                <el-input v-model="form.customLink" placeholder="请输入h5链接"></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8" v-if="form.source === '第三方'">
+              <el-form-item required label-width="140px">
+                <template #label>
+                  <span style="white-space: nowrap;">是否有详情页<el-tooltip content="没有详情页时直接从首页/列表页跳转" placement="top"><el-icon class="tip-icon"><QuestionFilled /></el-icon></el-tooltip></span>
+                </template>
+                <el-radio-group v-model="form.externalJumpMethod">
+                  <el-radio-button label="没有"></el-radio-button>
+                  <el-radio-button label="有"></el-radio-button>
+                </el-radio-group>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20" v-if="form.source === '第三方' && form.externalLink === '小程序链接'">
+            <el-col :span="8">
+              <el-form-item label="小程序appid" required>
+                <el-input v-model="form.miniAppId" placeholder="请输入小程序appid"></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="小程序页面地址" required label-width="140px">
+                <el-input v-model="form.miniAppPath" placeholder="请输入小程序页面地址"></el-input>
+              </el-form-item>
+            </el-col>
+          </el-row>
+
+          <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益名称" required>
+                  <el-input v-model="form.equityName" maxlength="40" placeholder="请输入权益名称"></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="权益副标题">
+                  <el-input v-model="form.subTitle" maxlength="40" placeholder="请输入权益副标题"></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.source === '荣数商品'">
+              <el-col :span="8">
+                <el-form-item label="库存数" required>
+                  <el-input-number
+                    v-model="form.stockCount"
+                    :min="0"
+                    :max="100000"
+                    style="width: 100%"
+                  ></el-input-number>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item required>
+                  <template #label>
+                    <span>权益图标
+                      <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                        <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                      </el-tooltip>
+                    </span>
+                  </template>
+                  <el-upload
+                    action=""
+                    :auto-upload="false"
+                    :file-list="form.listIcon"
+                    :on-change="file => handleUpload(file, 'listIcon')"
+                    :on-preview="handlePreviewImage"
+                    :on-remove="() => form.listIcon = []"
+                    :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                    :limit="1"
+                    list-type="picture-card"
+                  >
+                    <el-icon><Plus /></el-icon>
+                  </el-upload>
+                  <div class="upload-size-tip">建议尺寸:80x80px</div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </template>
+
+          <template v-else-if="form.source === '第三方'">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益名称" required>
+                  <el-input v-model="form.equityName" maxlength="40" placeholder="请输入权益名称"></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="权益副标题">
+                  <el-input v-model="form.subTitle" maxlength="40" placeholder="请输入权益副标题"></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.externalJumpMethod === '没有'">
+              <el-col :span="8">
+                <el-form-item required>
+                  <template #label>
+                    <span>权益图标
+                      <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                        <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                      </el-tooltip>
+                    </span>
+                  </template>
+                  <el-upload
+                    action=""
+                    :auto-upload="false"
+                    :file-list="form.listIcon"
+                    :on-change="file => handleUpload(file, 'listIcon')"
+                    :on-preview="handlePreviewImage"
+                    :on-remove="() => form.listIcon = []"
+                    :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                    :limit="1"
+                    list-type="picture-card"
+                  >
+                    <el-icon><Plus /></el-icon>
+                  </el-upload>
+                  <div class="upload-size-tip">建议尺寸:80x80px</div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row v-if="form.externalJumpMethod === '有'" :gutter="20">
+              <el-col :span="8">
+                <el-form-item required>
+                  <template #label>
+                    <span>权益图标
+                      <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                        <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                      </el-tooltip>
+                    </span>
+                  </template>
+                  <el-upload
+                    action=""
+                    :auto-upload="false"
+                    :file-list="form.listIcon"
+                    :on-change="file => handleUpload(file, 'listIcon')"
+                    :on-preview="handlePreviewImage"
+                    :on-remove="() => form.listIcon = []"
+                    :class="{ 'upload-hide-trigger': form.listIcon.length >= 1 }"
+                    :limit="1"
+                    list-type="picture-card"
+                  >
+                    <el-icon><Plus /></el-icon>
+                  </el-upload>
+                  <div class="upload-size-tip">建议尺寸:80x80px</div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </template>
+        </el-form>
+      </el-card>
+
+      <!-- 详情信息 -->
+      <el-card v-if="shouldShowDetailCard" shadow="never" class="section-card">
+        <template #header>
+          <div class="section-title">详情信息</div>
+        </template>
+        <el-form :model="form" label-width="120px" label-position="left">
+          <el-row :gutter="20">
+            <el-col :span="8">
+              <el-form-item label="权益主图" required>
+                <el-upload
+                  action=""
+                  :auto-upload="false"
+                  :file-list="form.mainImage"
+                  :on-change="file => handleUpload(file, 'mainImage')"
+                  :on-preview="handlePreviewImage"
+                  :on-remove="() => form.mainImage = []"
+                  :class="{ 'upload-hide-trigger': form.mainImage.length >= 1 }"
+                  :limit="1"
+                  list-type="picture-card"
+                >
+                  <el-icon><Plus /></el-icon>
+                </el-upload>
+                <div class="upload-size-tip">建议尺寸:716x400px</div>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="权益介绍" required>
+                <el-input
+                  v-model="form.introduction"
+                  type="textarea"
+                  :rows="4"
+                  maxlength="500"
+                  show-word-limit
+                  placeholder="请输入权益介绍"
+                ></el-input>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="权益有效期" required>
+                <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+                  <el-radio-group v-model="form.validityType">
+                    <el-radio label="fromGrantPlan">取自发放计划</el-radio>
+                    <el-radio label="custom">自定义输入</el-radio>
+                  </el-radio-group>
+                  <el-input
+                    v-if="form.validityType === 'custom'"
+                    v-model="form.validityText"
+                    maxlength="50"
+                    show-word-limit
+                    placeholder="请输入权益有效期"
+                    style="margin-top: 8px;"
+                  ></el-input>
+                </template>
+                <el-input
+                  v-else
+                  v-model="form.validityText"
+                  maxlength="50"
+                  show-word-limit
+                  placeholder="请输入权益有效期"
+                ></el-input>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="权益内容" required>
+                <quill-editor v-model:content="form.overview" content-type="html" :options="quillOptions"></quill-editor>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="权益说明" required>
+                <quill-editor v-model:content="form.details" content-type="html" :options="quillOptions"></quill-editor>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+            <el-row :gutter="20">
+              <el-col :span="24">
+                <el-form-item>
+                  <template #label>
+                    <span>下单成功短信
+                      <el-tooltip placement="top">
+                        <template #content>
+                          <div>
+                            此模板为权益支付成功,发送给持卡人的短信模板;<br/>
+                            开启后需选择短信模板,关闭则不发送短信。<br/>
+                            占位符如下:<br/>
+                            权益名称:${productName}<br/>
+                            结束日期:${expireDate}<br/>
+                            卡号:${exchangeNum}<br/>
+                            卡密:${exchangePwd}<br/>
+                            券码路径:${qrCodeDownPaths}
+                          </div>
+                        </template>
+                        <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                      </el-tooltip>
+                    </span>
+                  </template>
+                  <el-switch v-model="form.smsEnabled" @change="handleSmsEnabledChange"></el-switch>
+                  <template v-if="form.smsEnabled">
+                    <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                      <el-select
+                        v-model="form.smsTemplateId"
+                        placeholder="请选择短信模板"
+                        style="width: 100%"
+                        @change="handleSmsTemplateChange"
+                      >
+                        <el-option
+                          v-for="item in smsTemplateOptions"
+                          :key="item.id"
+                          :label="item.name"
+                          :value="item.id"
+                        />
+                      </el-select>
+                      <div v-if="form.smsTemplate" class="sms-template-preview">{{ form.smsTemplate }}</div>
+                    </div>
+                  </template>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="领取成功按钮" required>
+                  <el-input v-model="form.successButtonText" placeholder="请输入按钮文案"></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </template>
+        </el-form>
+      </el-card>
+
+      </div>
+
+      <div v-show="activeStep === 1">
+        <!-- 设置规则 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">设置规则</div>
+            <div class="section-title">展示规则</div>
+          </template>
+        <el-form :model="form" label-width="120px" label-position="left">
+          <el-form-item label="展示时间限制">
+            <el-switch v-model="form.showTimeEnabled"></el-switch>
+            <template v-if="form.showTimeEnabled">
+              <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                <el-date-picker
+                  v-model="form.showStartTime"
+                  type="datetime"
+                  placeholder="请选择开始时间"
+                  format="yyyy-MM-dd HH:mm:ss"
+                  value-format="yyyy-MM-dd HH:mm:ss"
+                  style="width: 100%"
+                ></el-date-picker>
+                <div style="width: 50px;text-align: center;">-</div>
+                <el-date-picker
+                  v-model="form.showEndTime"
+                  type="datetime"
+                  placeholder="请选择结束时间"
+                  format="yyyy-MM-dd HH:mm:ss"
+                  value-format="yyyy-MM-dd HH:mm:ss"
+                  style="width: 100%"
+                ></el-date-picker>
+              </div>
+            </template>
+          </el-form-item>
+          <el-form-item label="展示客群限制">
+            <el-switch v-model="form.showGroupEnabled"></el-switch>
+            <template v-if="form.showGroupEnabled">
+              <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                <el-select v-model="form.showGroupTagIds" multiple filterable placeholder="请选择客群" style="width: 100%">
+                  <el-option label="客群A" value="客群A"></el-option>
+                  <el-option label="客群B" value="客群B"></el-option>
+                  <el-option label="客群C" value="客群C"></el-option>
+                </el-select>
+              </div>
+            </template>
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 领取规则 -->
+      <el-card shadow="never" class="section-card">
+        <template #header>
+          <div class="section-title">领取规则</div>
+        </template>
+        <el-form :model="form" label-width="120px" label-position="left">
+          <el-form-item label="领取时间限制">
+            <el-switch v-model="form.claimTimeEnabled"></el-switch>
+            <template v-if="form.claimTimeEnabled">
+              <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                <el-date-picker
+                  v-model="form.claimStartTime"
+                  type="datetime"
+                  placeholder="请选择开始时间"
+                  format="yyyy-MM-dd HH:mm:ss"
+                  value-format="yyyy-MM-dd HH:mm:ss"
+                  style="width: 100%"
+                ></el-date-picker>
+                <div style="width: 50px;text-align: center;">-</div>
+                <el-date-picker
+                  v-model="form.claimEndTime"
+                  type="datetime"
+                  placeholder="请选择结束时间"
+                  format="yyyy-MM-dd HH:mm:ss"
+                  value-format="yyyy-MM-dd HH:mm:ss"
+                  style="width: 100%"
+                ></el-date-picker>
+              </div>
+            </template>
+          </el-form-item>
+          <el-form-item label="领取客群限制">
+            <el-switch v-model="form.claimGroupEnabled"></el-switch>
+            <template v-if="form.claimGroupEnabled">
+              <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                <el-alert
+                  title="需在【发放计划】中选择权益发放,未发放的权益移动端不展示"
+                  type="info"
+                  :closable="false"
+                  show-icon
+                  style="margin-bottom: 12px;"
+                ></el-alert>
+                <div style="font-size: 14px;color: #606266;margin-bottom: 8px;font-weight: 500;">不满足提示</div>
+                <el-form-item label="提示内容" label-width="80px" style="margin-bottom: 12px;">
+                  <el-input v-model="form.claimDenyMessage" maxlength="200" placeholder="请输入提示内容"></el-input>
+                </el-form-item>
+                <el-form-item label="跳转按钮文案" label-width="100px" style="margin-bottom: 12px;">
+                  <el-input v-model="form.claimDenyButtonText" maxlength="10" placeholder="请输入按钮文案"></el-input>
+                </el-form-item>
+                <el-form-item label="跳转按钮链接" label-width="100px" style="margin-bottom: 0;">
+                  <el-input v-model="form.claimDenyButtonLink" maxlength="200" placeholder="请输入按钮链接"></el-input>
+                </el-form-item>
+              </div>
+            </template>
+          </el-form-item>
+        </el-form>
+      </el-card>
+      </div>
+
+      <div v-show="activeStep === 2">
+        <!-- 显示页面 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">显示页面</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="权益首页/列表页">
+              <el-radio-group v-model="form.showOnHome">
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="组装页">
+              <el-radio-group v-model="form.showOnAssembly">
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+              <div
+                v-if="form.showOnAssembly === '显示'"
+                style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;"
+              >
+                <el-select
+                  v-model="form.assemblyPages"
+                  multiple
+                  filterable
+                  placeholder="请选择组装页"
+                  style="width: 100%"
+                >
+                  <el-option label="组装页A" value="组装页A"></el-option>
+                  <el-option label="组装页B" value="组装页B"></el-option>
+                  <el-option label="组装页C" value="组装页C"></el-option>
+                  <el-option label="组装页D" value="组装页D"></el-option>
+                </el-select>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 分享设置 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">分享设置</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="是否支持分享">
+              <el-switch v-model="form.shareEnabled" @change="handleShareEnabledChange"></el-switch>
+              <template v-if="form.shareEnabled">
+                <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                  <el-form-item label="分享名称" label-width="100px" required style="margin-bottom: 16px;">
+                    <el-input
+                      v-model="form.shareName"
+                      maxlength="40"
+                      placeholder="请输入分享名称"
+                    ></el-input>
+                  </el-form-item>
+                  <el-form-item label="分享图片" label-width="100px" required style="margin-bottom: 0;">
+                    <el-upload
+                      action=""
+                      :auto-upload="false"
+                      :file-list="form.shareImage"
+                      :on-change="file => handleUpload(file, 'shareImage')"
+                      :limit="1"
+                      list-type="picture-card"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                  </el-form-item>
+                </div>
+              </template>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 操作栏 -->
+      <div class="action-bar">
+        <div class="action-left">
+          <el-button
+            v-if="activeStep > 0"
+            @click="prevStep"
+          >
+            <el-icon><ArrowLeft /></el-icon>上一步
+          </el-button>
+        </div>
+        <div class="action-center">
+          <el-button @click="goBack">取消</el-button>
+          <el-button
+            v-if="currentAuditStatus === '草稿'"
+            type="primary"
+            plain
+            @click="handleSaveDraft"
+          >保存草稿</el-button>
+          <el-button
+            v-if="activeStep === 2"
+            type="primary"
+            @click="handleSubmitAudit"
+          >提交审核</el-button>
+        </div>
+        <div class="action-right">
+          <el-button
+            v-if="activeStep < 2"
+            type="primary"
+            @click="nextStep"
+          >下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <rongshu-product-dialog
+      v-model:visible="rongshuDialogVisible"
+      @select="onRongshuSelect"
+    />
+
+    <el-dialog v-model="previewVisible" title="图片预览" width="640px" append-to-body>
+      <img v-if="previewImageUrl" :src="previewImageUrl" style="display:block; max-width:100%; margin:0 auto;" />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import RongshuProductDialog from '@/components/RongshuProductDialog.vue'
+import request from '@/utils/request'
+
+export default {
+  name: 'EditEquityProduct',
+  components: { RongshuProductDialog },
+  data() {
+    return {
+      activeStep: 0,
+      rongshuDialogVisible: false,
+      previewVisible: false,
+      previewImageUrl: '',
+      currentShelfStatus: '',
+      currentAuditStatus: '',
+      quillOptions: {
+        placeholder: '请输入内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      },
+      smsTemplateOptions: [
+        { id: 'SMS_TPL_001', name: '通用权益支付成功模板', content: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}' },
+        { id: 'SMS_TPL_002', name: '券码类短信模板', content: '【银行】尊敬的客户,您的${productName}购买成功,券码为${exchangeNum},请于${expireDate}前使用。' },
+        { id: 'SMS_TPL_003', name: '实物类短信模板', content: '【银行】您购买的${productName}已受理,我们将尽快安排发货,感谢您的支持。' }
+      ],
+      form: {
+        source: '银行自有',
+        rongshuProduct: '',
+        productType: '券码',
+        faceValueThreshold: '',
+        faceValueDiscount: '',
+        stockCount: null,
+        shareEnabled: false,
+        shareImage: [],
+        shareName: '',
+        externalJumpMethod: '没有',
+        externalLink: '太古地产',
+        customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+        skuNo: '',
+        equityName: '',
+        productId: '',
+        merchantName: '',
+        subTitle: '',
+        listIcon: [],
+        mainImage: [],
+        introduction: '',
+        validityType: 'fromGrantPlan',
+        validityText: '',
+        overview: '',
+        details: '',
+        smsEnabled: false,
+        smsTemplateId: '',
+        smsTemplate: '',
+        successButtonText: '',
+        showTimeEnabled: false,
+        showStartTime: '',
+        showEndTime: '',
+        showGroupEnabled: false,
+        showGroupTagIds: [],
+        claimTimeEnabled: false,
+        claimStartTime: '',
+        claimEndTime: '',
+        claimGroupEnabled: false,
+        claimGroupIsPotential: false,
+        claimGroupTagId: '',
+        claimDenyMessage: '',
+        claimDenyButtonText: '',
+        claimDenyButtonLink: '',
+        showOnHome: '显示',
+        showOnAssembly: '不显示',
+        assemblyPages: []
+      }
+    }
+  },
+  computed: {
+    shouldShowDetailCard() {
+      const s = this.form.source
+      if (s === '银行自有' || s === '荣数商品') return true
+      if (s === '第三方' && this.form.externalJumpMethod === '有') return true
+      return false
+    }
+  },
+  created() {
+    this.loadData()
+    this.loadStatus()
+  },
+  methods: {
+    loadStatus() {
+      const id = this.$route.params.id
+      if (!id) return
+      request.get(`/api/equity/detail?id=${id}`).then(res => {
+        if (res.data) {
+          this.currentShelfStatus = res.data.shelfStatus || ''
+          this.currentAuditStatus = res.data.auditStatus || ''
+          // 用 API 返回的核心字段覆盖(保证编辑/已上架的内容是当前线上版本)
+          const fields = ['equityName', 'source', 'productType', 'skuNo', 'merchantName', 'subTitle']
+          fields.forEach(k => {
+            if (res.data[k] !== undefined && res.data[k] !== null && res.data[k] !== '') {
+              this.form[k] = res.data[k]
+            }
+          })
+          this.form.productId = id
+        }
+      }).catch(() => {})
+    },
+    buildPayload() {
+      const f = this.form
+      const isCustomValidity = f.source === '第三方' || f.validityType === 'custom'
+      return {
+        productId: f.productId || this.$route.params.id,
+        equityName: f.equityName,
+        source: f.source,
+        productType: f.productType || '',
+        skuNo: f.skuNo || '',
+        merchantName: f.merchantName || '',
+        subTitle: f.subTitle || '',
+        jumpMethod: f.source === '第三方' ? f.externalJumpMethod : '-',
+        jumpLink: f.source === '第三方' ? f.externalLink : '-',
+        exchangeMethod: f.source === '第三方'
+          ? (f.externalJumpMethod === '有' ? '第三方(详情页跳转)' : '第三方(列表跳转)')
+          : '权益平台',
+        validityType: isCustomValidity ? 'custom' : 'fromGrantPlan',
+        validityText: isCustomValidity ? (f.validityText || '') : '',
+        showOnHome: f.showOnHome,
+        showOnAssembly: f.showOnAssembly,
+        assemblyPages: [...(f.assemblyPages || [])]
+      }
+    },
+    handleSaveDraft() {
+      request.post('/api/equity/save-draft', this.buildPayload()).then(() => {
+        ElMessage.success('草稿已保存')
+        this.$router.push('/customization/hsbc/equity-products')
+      }).catch(() => {})
+    },
+    handleSubmitAudit() {
+      const tip = '提交后将进入审核流程,期间不可再编辑。'
+      ElMessageBox.prompt(tip + '\n请填写提交说明(可选)', '提交审核', {
+        confirmButtonText: '提交',
+        cancelButtonText: '取消',
+        inputType: 'textarea',
+        inputPlaceholder: '可填写本次提交的关键变更点',
+        inputValidator: () => true
+      }).then(({ value }) => {
+        const payload = { ...this.buildPayload(), submitReason: value || '' }
+        request.post('/api/equity/submit', payload).then(() => {
+          ElMessage.success('已提交审核')
+          this.$router.push('/customization/hsbc/equity-products')
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    loadData() {
+      const id = this.$route.params.id
+      const mockMap = {
+        'Q100001': {
+          source: '银行自有',
+          externalJumpMethod: '',
+          externalLink: '',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU001',
+          equityName: '权益A',
+          subTitle: '副标题示例',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>权益内容内容</p>',
+          details: '<p>权益说明内容</p>',
+          smsEnabled: true,
+          smsTemplateId: 'SMS_TPL_001',
+          smsTemplate: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}',
+          successButtonText: '马上去使用',
+          showTimeEnabled: true,
+          showStartTime: '2026-01-01 00:00:00',
+          showEndTime: '2026-12-31 23:59:59',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-01-01 00:00:00',
+          claimEndTime: '2026-12-31 23:59:59',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100002': {
+          source: '第三方',
+          externalJumpMethod: '没有',
+          externalLink: '太古地产',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU002',
+          equityName: '权益B',
+          subTitle: '外部列表页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '',
+          details: '',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: true,
+          showGroupTagIds: ['客群A'],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-02-01 00:00:00',
+          claimEndTime: '2026-11-30 23:59:59',
+          claimGroupEnabled: true,
+          claimGroupIsPotential: false,
+          claimGroupTagId: '客群奖励活动A',
+          claimDenyMessage: '您暂不满足领取条件',
+          claimDenyButtonText: '去升级',
+          claimDenyButtonLink: 'https://example.com/upgrade'
+        },
+        'Q100003': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: '龙腾',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU003',
+          equityName: '权益C',
+          subTitle: '外部详情页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>外部详情页权益内容</p>',
+          details: '<p>外部详情页权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100004': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: 'h5链接',
+          customLink: 'https://example.com/custom',
+          skuNo: 'SKU004',
+          equityName: '权益D',
+          subTitle: '外部自定义链接副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>自定义链接权益内容</p>',
+          details: '<p>自定义链接权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        }
+      }
+      if (id && mockMap[id]) {
+        Object.assign(this.form, mockMap[id])
+      }
+    },
+    nextStep() {
+      if (this.activeStep < 2) {
+        this.activeStep++
+      }
+    },
+    prevStep() {
+      if (this.activeStep > 0) {
+        this.activeStep--
+      }
+    },
+    goBack() {
+      this.$router.push('/customization/hsbc/equity-products')
+    },
+    handleSmsEnabledChange(val) {
+      if (!val) {
+        this.form.smsTemplateId = ''
+        this.form.smsTemplate = ''
+      }
+    },
+    handleShareEnabledChange(val) {
+      if (val) {
+        if (!this.form.shareName) {
+          this.form.shareName = this.form.equityName || ''
+        }
+      }
+    },
+    handleSmsTemplateChange(id) {
+      const tpl = this.smsTemplateOptions.find(i => i.id === id)
+      this.form.smsTemplate = tpl ? tpl.content : ''
+    },
+    handleUpload(file, field) {
+      if (!file || !file.raw) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        this.form[field] = [{ name: file.name, url: e.target.result, uid: file.uid }]
+      }
+      reader.readAsDataURL(file.raw)
+    },
+    handlePreviewImage(file) {
+      this.previewImageUrl = file.url || ''
+      this.previewVisible = !!this.previewImageUrl
+    },
+    onRongshuSelect(product) {
+      this.form.rongshuProduct = product.name
+      this.form.equityName = product.name
+      this.form.merchantName = product.merchant
+      this.form.stockCount = product.stock
+      this.form.mainImage = product.mainImage ? [...product.mainImage] : []
+    }
+  }
+}
+</script>
+
+<style scoped>
+.edit-equity-product {
+  padding: 20px;
+}
+.header-title {
+  font-size: 18px;
+  font-weight: 500;
+}
+.section-card {
+  margin-bottom: 16px;
+  border: none;
+}
+.upload-hide-trigger >>> .el-upload--picture-card { display: none; }
+
+.section-card >>> .el-card__header {
+  padding: 12px 20px;
+  background: #fafafa;
+  border-bottom: 1px solid #ebeef5;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+.action-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+.action-left, .action-right {
+  width: 120px;
+}
+.action-center {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+.tip-icon {
+  color: #909399;
+  cursor: pointer;
+  font-size: 14px;
+}
+.upload-size-tip {
+  margin-top: 4px;
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
+.sms-template-preview {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 214 - 0
pc-v3/src/views/EquityGrantCampaign.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="equity-grant-campaign">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="100px" inline>
+            <el-form-item label="发放计划名称:">
+              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:220px" />
+            </el-form-item>
+            <el-form-item label="发放计划时间:">
+              <el-date-picker
+                v-model="searchForm.dateRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="yyyy-MM-dd"
+                style="width:360px"
+              />
+            </el-form-item>
+            <el-form-item label="状态:">
+              <el-select v-model="searchForm.status" placeholder="全部" clearable style="width:140px">
+                <el-option label="未开始" value="未开始" />
+                <el-option label="进行中" value="进行中" />
+                <el-option label="已结束" value="已结束" />
+              </el-select>
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" @click="handleCreate">
+            <el-icon><Plus /></el-icon>新建发放计划
+          </el-button>
+        </div>
+        </div>
+      </template>
+
+      <el-table :data="filteredData" stripe border style="width:100%">
+        <el-table-column label="操作" width="240" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleView(scope.row)">查看</el-button>
+            <el-button
+              v-if="statusLabel(scope.row) !== '进行中'"
+              type="text"
+              size="small"
+              @click="handleEdit(scope.row)"
+            >编辑</el-button>
+            <el-button
+              v-if="statusLabel(scope.row) === '进行中'"
+              type="text"
+              size="small"
+              style="color:#E6A23C"
+              @click="handleEnd(scope.row)"
+            >结束</el-button>
+            <el-button
+              v-if="statusLabel(scope.row) !== '进行中'"
+              type="text"
+              size="small"
+              style="color:#F56C6C"
+              @click="handleDelete(scope.row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="发放计划 ID" width="160" align="center" />
+        <el-table-column prop="name" label="发放计划名称" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="startTime" label="开始时间" width="170" align="center" />
+        <el-table-column prop="endTime" label="结束时间" width="170" align="center" />
+        <el-table-column label="状态" width="100" align="center">
+          <template #default="scope">
+            <el-tag :type="statusTag(scope.row)" size="small">{{ statusLabel(scope.row) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="获取方式" width="110" align="center">
+          <template #default="scope">
+            <el-tag :type="scope.row.grantMode === 'direct' ? 'warning' : 'success'" size="small">
+              {{ scope.row.grantMode === 'direct' ? '直接发放' : '用户领取' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="权益数量" width="150" align="center">
+          <template #default="scope">
+            {{ grantSummary(scope.row) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="权益有效期" min-width="200" align="center">
+          <template #default="scope">
+            {{ validitySummary(scope.row.validity) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="170" align="center" />
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import request from '@/utils/request'
+
+const grantCycleLabelMap = { total: '整个周期', monthly: '每月', quarterly: '每季度', yearly: '每年' }
+
+export default {
+  name: 'EquityGrantCampaign',
+  data() {
+    return {
+      searchForm: { name: '', dateRange: [], status: '' },
+      tableData: []
+    }
+  },
+  computed: {
+    filteredData() {
+      if (!this.searchForm.status) return this.tableData
+      return this.tableData.filter(r => this.statusLabel(r) === this.searchForm.status)
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  activated() {
+    this.fetchList()
+  },
+  watch: {
+    $route(to) {
+      if (to.path === '/customization/hsbc/equity-grant-campaign') {
+        this.fetchList()
+      }
+    }
+  },
+  methods: {
+    async fetchList() {
+      const params = { name: this.searchForm.name }
+      if (this.searchForm.dateRange && this.searchForm.dateRange.length === 2) {
+        params.startDate = this.searchForm.dateRange[0]
+        params.endDate = this.searchForm.dateRange[1]
+      }
+      const res = await request.get('/api/equity-grant-campaign/list', { params })
+      this.tableData = res.data || []
+    },
+    handleSearch() { this.fetchList() },
+    handleReset() {
+      this.searchForm = { name: '', dateRange: [], status: '' }
+      this.fetchList()
+    },
+    handleCreate() {
+      this.$router.push('/customization/hsbc/equity-grant-campaign/add')
+    },
+    handleEdit(row) {
+      this.$router.push(`/customization/hsbc/equity-grant-campaign/edit/${row.id}`)
+    },
+    handleView(row) {
+      this.$router.push(`/customization/hsbc/equity-grant-campaign/view/${row.id}`)
+    },
+    async handleEnd(row) {
+      await ElMessageBox.confirm(
+        `确定结束发放计划「${row.name}」吗?结束后将立即生效,且不再继续发放权益。`,
+        '结束发放计划',
+        { type: 'warning', confirmButtonText: '确认结束', cancelButtonText: '取消' }
+      )
+      await request.post('/api/equity-grant-campaign/end', { id: row.id })
+      ElMessage.success('已结束')
+      this.fetchList()
+    },
+    async handleDelete(row) {
+      await ElMessageBox.confirm('您确定要删除此条发放计划吗?删除后将无法恢复。', '确认删除', {
+        type: 'warning',
+        confirmButtonText: '确认删除',
+        cancelButtonText: '取消'
+      })
+      await request.post('/api/equity-grant-campaign/delete', { id: row.id })
+      ElMessage.success('删除成功')
+      this.fetchList()
+    },
+    validitySummary(v) {
+      if (!v) return '-'
+      if (v.type === 'fixed') {
+        if (!v.fixedRange || v.fixedRange.length !== 2) return '固定日期'
+        return `${v.fixedRange[0]} 至 ${v.fixedRange[1]}`
+      }
+      if (v.type === 'relative') {
+        return `自发放起 ${v.relativeDays || 0} 天内有效`
+      }
+      return '-'
+    },
+    grantSummary(row) {
+      if (!row) return '-'
+      const cycle = grantCycleLabelMap[row.grantCycle] || '整个周期'
+      return `${cycle} ${row.grantQuantity || 0} 个`
+    },
+    statusLabel(row) {
+      const now = Date.now()
+      const s = new Date(row.startTime).getTime()
+      const e = new Date(row.endTime).getTime()
+      if (now < s) return '未开始'
+      if (now > e) return '已结束'
+      return '进行中'
+    },
+    statusTag(row) {
+      const l = this.statusLabel(row)
+      return { 未开始: 'info', 进行中: 'success', 已结束: '' }[l] || ''
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section .search-row { margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.button-group { display: inline-block; margin-left: 20px; }
+</style>

+ 378 - 0
pc-v3/src/views/EquityGrantCampaignEdit.vue

@@ -0,0 +1,378 @@
+<template>
+  <div class="equity-grant-campaign-edit">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">{{ pageTitle }}</span>
+        </div>
+      </template>
+
+      <el-form
+        ref="form"
+        :model="form"
+        :rules="rules"
+        label-width="150px"
+        style="max-width: 920px; margin: 0 auto"
+      >
+        <el-form-item v-if="isEdit" label="发放计划 ID">
+          <el-input :value="form.id" disabled style="width:320px" />
+        </el-form-item>
+        <el-form-item label="发放计划名称" prop="name">
+          <el-input
+            v-model="form.name"
+            placeholder="请输入发放计划名称"
+            maxlength="50"
+            show-word-limit
+            style="width:420px"
+          />
+        </el-form-item>
+        <el-form-item label="发放计划开始时间" prop="startTime">
+          <el-date-picker
+            v-model="form.startTime"
+            type="datetime"
+            placeholder="选择开始时间"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width:320px"
+          />
+        </el-form-item>
+        <el-form-item label="发放计划结束时间" prop="endTime">
+          <el-date-picker
+            v-model="form.endTime"
+            type="datetime"
+            placeholder="选择结束时间"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width:320px"
+          />
+        </el-form-item>
+        <el-form-item label="选择客群" prop="crowdList">
+          <el-select
+            v-model="form.crowdList"
+            multiple
+            filterable
+            collapse-tags
+            placeholder="请选择客群(可多选,支持搜索)"
+            style="width:100%"
+          >
+            <el-option
+              v-for="item in crowdOptions"
+              :key="item.id"
+              :label="`${item.name} (${item.id})`"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="获取方式" prop="grantMode" required>
+          <el-radio-group v-model="form.grantMode" @change="handleGrantModeChange">
+            <el-radio label="claim">用户领取</el-radio>
+            <el-radio label="direct">直接发放</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="选择权益" prop="equityList">
+          <el-select
+            v-if="isClaimMode"
+            v-model="form.equityList"
+            multiple
+            filterable
+            collapse-tags
+            placeholder="请选择借记卡权益(可多选)"
+            style="width:100%"
+          >
+            <el-option
+              v-for="item in equityOptions"
+              :key="item.productId"
+              :label="`${item.equityName} (${item.productId})`"
+              :value="item.productId"
+            />
+          </el-select>
+          <el-select
+            v-else
+            v-model="singleEquityId"
+            filterable
+            placeholder="请选择借记卡权益(仅可单选)"
+            style="width:100%"
+          >
+            <el-option
+              v-for="item in equityOptions"
+              :key="item.productId"
+              :label="`${item.equityName} (${item.productId})`"
+              :value="item.productId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="权益数量" required>
+          <div class="grant-row">
+            <el-select v-model="form.grantCycle" placeholder="类型" style="width:140px">
+              <el-option label="整个周期" value="total" />
+              <el-option label="每月" value="monthly" />
+              <el-option label="每季度" value="quarterly" />
+              <el-option label="每年" value="yearly" />
+            </el-select>
+            <el-input-number
+              v-model="form.grantQuantity"
+              :min="1"
+              :max="9999"
+              :controls-position="'right'"
+              style="width:200px;margin-left:12px"
+            />
+            <span class="grant-suffix">个</span>
+          </div>
+        </el-form-item>
+        <el-form-item label="权益有效期" required>
+          <el-radio-group v-model="form.validity.type">
+            <el-radio label="fixed">固定日期</el-radio>
+            <el-radio label="relative">相对日期</el-radio>
+          </el-radio-group>
+          <div v-if="form.validity.type === 'fixed'" class="validity-row">
+            <el-date-picker
+              v-model="form.validity.fixedRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              value-format="yyyy-MM-dd"
+              style="width:360px"
+            />
+          </div>
+          <div v-else class="validity-row">
+            <el-input-number
+              v-model="form.validity.relativeDays"
+              :min="1"
+              :max="3650"
+              :controls-position="'right'"
+              style="width:200px"
+            />
+            <span class="grant-suffix">天(自发放起算)</span>
+          </div>
+        </el-form-item>
+        <el-form-item label="短信通知" prop="smsTemplateId" required>
+          <el-select
+            v-model="form.smsTemplateId"
+            placeholder="请选择短信模板"
+            style="width:420px"
+            @change="handleSmsTemplateChange"
+          >
+            <el-option
+              v-for="item in smsTemplateOptions"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+          <div v-if="form.smsTemplate" class="sms-template-preview">{{ form.smsTemplate }}</div>
+        </el-form-item>
+      </el-form>
+
+      <div class="footer">
+        <el-button @click="handleCancel">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">保存</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import request from '@/utils/request'
+import crowdNameListStore from '@/store/crowdNameListStore'
+
+function emptyForm() {
+  return {
+    id: '',
+    name: '',
+    startTime: '',
+    endTime: '',
+    crowdList: [],
+    grantMode: 'claim',
+    equityList: [],
+    grantCycle: 'total',
+    grantQuantity: 1,
+    validity: { type: 'fixed', fixedRange: [], relativeDays: 30 },
+    smsTemplateId: '',
+    smsTemplate: ''
+  }
+}
+
+const SMS_TEMPLATE_OPTIONS = [
+  { id: 'GRANT_SMS_001', name: '发放成功通用模板', content: '【银行】尊敬的${customerName},您已获得${productName}权益,发放时间:${grantTime},请于${expireDate}前使用。' },
+  { id: 'GRANT_SMS_002', name: '发放成功-含查看入口', content: '【银行】尊敬的${customerName},您已获得${productName}权益,有效期至${expireDate},请登录APP查看详情。' },
+  { id: 'GRANT_SMS_003', name: '发放成功-简洁版', content: '【银行】${customerName}您好,${productName}已为您发放成功,有效期至${expireDate}。' }
+]
+
+export default {
+  name: 'EquityGrantCampaignEdit',
+  data() {
+    return {
+      form: emptyForm(),
+      equityOptions: [],
+      smsTemplateOptions: SMS_TEMPLATE_OPTIONS,
+      rules: {
+        name: [{ required: true, message: '请输入发放计划名称', trigger: 'blur' }],
+        startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
+        endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
+        crowdList: [
+          { type: 'array', required: true, message: '请至少选择一个客群', trigger: 'change' }
+        ],
+        grantMode: [
+          { required: true, message: '请选择获取方式', trigger: 'change' }
+        ],
+        equityList: [
+          {
+            validator: (rule, value, cb) => {
+              if (!value || value.length === 0) {
+                return cb(new Error(this.isClaimMode ? '请至少选择一个权益' : '请选择一个权益'))
+              }
+              cb()
+            },
+            trigger: 'change'
+          }
+        ],
+        smsTemplateId: [
+          { required: true, message: '请选择短信模板', trigger: 'change' }
+        ]
+      }
+    }
+  },
+  computed: {
+    isEdit() {
+      return this.$route.name === 'EquityGrantCampaignEdit'
+    },
+    pageTitle() {
+      return this.isEdit ? '编辑发放计划' : '新建发放计划'
+    },
+    crowdOptions() {
+      return crowdNameListStore.list
+    },
+    isClaimMode() {
+      return this.form.grantMode === 'claim'
+    },
+    singleEquityId: {
+      get() {
+        return (this.form.equityList && this.form.equityList[0]) || ''
+      },
+      set(val) {
+        this.form.equityList = val ? [val] : []
+      }
+    }
+  },
+  async created() {
+    await this.fetchEquityOptions()
+    if (this.isEdit) await this.loadDetail()
+  },
+  methods: {
+    async fetchEquityOptions() {
+      const res = await request.get('/api/equity/list', { params: { shelfStatus: '已上架' } })
+      this.equityOptions = res.data || []
+    },
+    async loadDetail() {
+      const id = this.$route.params.id
+      const res = await request.get('/api/equity-grant-campaign/detail', { params: { id } })
+      const row = res.data
+      if (!row) {
+        ElMessage.error('发放计划不存在')
+        this.$router.replace('/customization/hsbc/equity-grant-campaign')
+        return
+      }
+      const now = Date.now()
+      const s = new Date(row.startTime).getTime()
+      const e = new Date(row.endTime).getTime()
+      if (now >= s && now <= e) {
+        ElMessage.warning('进行中的发放计划不可编辑')
+        this.$router.replace('/customization/hsbc/equity-grant-campaign')
+        return
+      }
+      const v = row.validity || { type: 'fixed', fixedRange: [], relativeDays: 30 }
+      this.form = {
+        id: row.id,
+        name: row.name,
+        startTime: row.startTime,
+        endTime: row.endTime,
+        crowdList: [...(row.crowdList || [])],
+        grantMode: row.grantMode || 'claim',
+        equityList: [...(row.equityList || [])],
+        grantCycle: row.grantCycle || 'total',
+        grantQuantity: row.grantQuantity || 1,
+        validity: {
+          type: v.type || 'fixed',
+          fixedRange: Array.isArray(v.fixedRange) ? [...v.fixedRange] : [],
+          relativeDays: v.relativeDays || 30
+        },
+        smsTemplateId: row.smsTemplateId || '',
+        smsTemplate: row.smsTemplate || ''
+      }
+    },
+    handleGrantModeChange(mode) {
+      if (mode === 'direct' && this.form.equityList.length > 1) {
+        this.form.equityList = [this.form.equityList[0]]
+      }
+    },
+    handleSmsTemplateChange(id) {
+      const tpl = this.smsTemplateOptions.find(i => i.id === id)
+      this.form.smsTemplate = tpl ? tpl.content : ''
+    },
+    handleSubmit() {
+      this.$refs.form.validate(async valid => {
+        if (!valid) return
+        if (this.form.startTime && this.form.endTime && this.form.startTime >= this.form.endTime) {
+          ElMessage.warning('结束时间必须晚于开始时间')
+          return
+        }
+        const v = this.form.validity
+        if (v.type === 'fixed') {
+          if (!v.fixedRange || v.fixedRange.length !== 2) {
+            ElMessage.warning('请选择权益有效期的开始与结束日期')
+            return
+          }
+        } else if (!v.relativeDays || v.relativeDays < 1) {
+          ElMessage.warning('请输入权益有效期的天数')
+          return
+        }
+        if (!this.form.grantQuantity || this.form.grantQuantity < 1) {
+          ElMessage.warning('请输入权益数量')
+          return
+        }
+        if (!this.isClaimMode && this.form.equityList.length !== 1) {
+          ElMessage.warning('直接发放仅支持选择一个权益')
+          return
+        }
+        if (!this.form.smsTemplateId) {
+          ElMessage.warning('请选择短信模板')
+          return
+        }
+        await request.post('/api/equity-grant-campaign/save', this.form)
+        ElMessage.success(this.isEdit ? '保存成功' : '新建成功')
+        this.$router.push('/customization/hsbc/equity-grant-campaign')
+      })
+    },
+    handleCancel() {
+      this.$router.push('/customization/hsbc/equity-grant-campaign')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.equity-grant-campaign-edit { padding: 0; }
+.header { display: flex; align-items: center; justify-content: center; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+.grant-row { display: flex; align-items: center; }
+.grant-suffix { margin-left: 8px; color: #606266; }
+.validity-row { margin-top: 8px; display: flex; align-items: center; }
+.footer {
+  text-align: center;
+  margin-top: 24px;
+  padding-top: 16px;
+  border-top: 1px solid #ebeef5;
+}
+.footer .el-button { min-width: 100px; }
+.sms-template-preview {
+  margin-top: 8px;
+  padding: 10px 12px;
+  background: #f5f7fa;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  word-break: break-all;
+}
+</style>

+ 178 - 0
pc-v3/src/views/EquityGrantCampaignView.vue

@@ -0,0 +1,178 @@
+<template>
+  <div class="equity-grant-campaign-view">
+    <el-card v-if="current">
+      <template #header>
+        <div class="header">
+          <span class="title">查看发放计划</span>
+        </div>
+      </template>
+
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="发放计划 ID">{{ current.id }}</el-descriptions-item>
+        <el-descriptions-item label="发放计划名称">{{ current.name }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="statusTag" size="small">{{ statusLabel }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="获取方式">
+          <el-tag :type="current.grantMode === 'direct' ? 'warning' : 'success'" size="small">{{ grantModeLabel }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="权益数量">{{ grantSummary }}</el-descriptions-item>
+        <el-descriptions-item label="开始时间">{{ current.startTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="结束时间">{{ current.endTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="权益有效期" :span="2">{{ validitySummary }}</el-descriptions-item>
+        <el-descriptions-item label="选择客群" :span="2">
+          <template v-if="(current.crowdList || []).length">
+            <el-tag
+              v-for="cid in current.crowdList"
+              :key="cid"
+              size="small"
+              type="info"
+              style="margin-right:6px;margin-top:4px"
+            >{{ crowdLabel(cid) }}</el-tag>
+          </template>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="选择权益" :span="2">
+          <template v-if="(current.equityList || []).length">
+            <el-tag
+              v-for="pid in current.equityList"
+              :key="pid"
+              size="small"
+              style="margin-right:6px;margin-top:4px"
+            >{{ equityLabel(pid) }}</el-tag>
+          </template>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="短信通知" :span="2">
+          <div class="sms-info-row">
+            <span class="sms-tpl-name">{{ smsTemplateName }}</span>
+          </div>
+          <div v-if="current.smsTemplate" class="sms-template-preview">{{ current.smsTemplate }}</div>
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ current.createTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="更新时间">{{ current.updateTime || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <div class="footer">
+        <el-button type="primary" @click="goBack">返回</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import request from '@/utils/request'
+import crowdNameListStore from '@/store/crowdNameListStore'
+
+const grantCycleLabelMap = { total: '整个周期', monthly: '每月', quarterly: '每季度', yearly: '每年' }
+
+const SMS_TEMPLATE_NAME_MAP = {
+  GRANT_SMS_001: '发放成功通用模板',
+  GRANT_SMS_002: '发放成功-含查看入口',
+  GRANT_SMS_003: '发放成功-简洁版'
+}
+
+export default {
+  name: 'EquityGrantCampaignView',
+  data() {
+    return { current: null, equityOptions: [] }
+  },
+  computed: {
+    crowdOptions() {
+      return crowdNameListStore.list
+    },
+    statusLabel() {
+      if (!this.current) return ''
+      const now = Date.now()
+      const s = new Date(this.current.startTime).getTime()
+      const e = new Date(this.current.endTime).getTime()
+      if (now < s) return '未开始'
+      if (now > e) return '已结束'
+      return '进行中'
+    },
+    statusTag() {
+      return { 未开始: 'info', 进行中: 'success', 已结束: '' }[this.statusLabel] || ''
+    },
+    grantSummary() {
+      if (!this.current) return '-'
+      const cycle = grantCycleLabelMap[this.current.grantCycle] || '整个周期'
+      return `${cycle} ${this.current.grantQuantity || 0} 个`
+    },
+    grantModeLabel() {
+      return this.current && this.current.grantMode === 'direct' ? '直接发放' : '用户领取'
+    },
+    smsTemplateName() {
+      if (!this.current) return '-'
+      return SMS_TEMPLATE_NAME_MAP[this.current.smsTemplateId] || this.current.smsTemplateId || '-'
+    },
+    validitySummary() {
+      const v = this.current && this.current.validity
+      if (!v) return '-'
+      if (v.type === 'fixed') {
+        if (!v.fixedRange || v.fixedRange.length !== 2) return '固定日期'
+        return `${v.fixedRange[0]} 至 ${v.fixedRange[1]}`
+      }
+      if (v.type === 'relative') {
+        return `自发放起 ${v.relativeDays || 0} 天内有效`
+      }
+      return '-'
+    }
+  },
+  async created() {
+    await this.fetchEquityOptions()
+    await this.loadDetail()
+  },
+  methods: {
+    async fetchEquityOptions() {
+      const res = await request.get('/api/equity/list', { params: { shelfStatus: '已上架' } })
+      this.equityOptions = res.data || []
+    },
+    async loadDetail() {
+      const id = this.$route.params.id
+      const res = await request.get('/api/equity-grant-campaign/detail', { params: { id } })
+      this.current = res.data
+      if (!this.current) {
+        ElMessage.error('发放计划不存在')
+        this.$router.replace('/customization/hsbc/equity-grant-campaign')
+      }
+    },
+    equityLabel(productId) {
+      const m = this.equityOptions.find(i => i.productId === productId)
+      return m ? `${m.equityName} (${m.productId})` : productId
+    },
+    crowdLabel(crowdId) {
+      const m = this.crowdOptions.find(i => i.id === crowdId)
+      return m ? `${m.name} (${m.id})` : crowdId
+    },
+    goBack() {
+      this.$router.push('/customization/hsbc/equity-grant-campaign')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.equity-grant-campaign-view { padding: 0; }
+.header { display: flex; align-items: center; justify-content: center; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+.footer {
+  text-align: center;
+  margin-top: 24px;
+  padding-top: 16px;
+  border-top: 1px solid #ebeef5;
+}
+.sms-info-row { display: flex; align-items: center; gap: 8px; }
+.sms-tpl-name { color: #303133; }
+.sms-template-preview {
+  margin-top: 8px;
+  padding: 10px 12px;
+  background: #f5f7fa;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  word-break: break-all;
+}
+</style>

+ 520 - 0
pc-v3/src/views/EquityProductCoupons.vue

@@ -0,0 +1,520 @@
+<template>
+  <div class="equity-product-coupons">
+    <el-card>
+      <template #header>
+        <div class="header-title">
+          <span>券码管理</span>
+          <span class="product-id">权益名称:{{ equityName }}</span>
+        </div>
+      </template>
+
+      <div class="toolbar">
+        <el-button type="primary" @click="openImportDialog">
+          <el-icon><Upload /></el-icon>导入券码
+        </el-button>
+      </div>
+
+      <el-table :data="batchList" stripe style="width: 100%">
+        <el-table-column prop="importCount" label="导入数量" width="100"></el-table-column>
+        <el-table-column prop="actualImportCount" label="实际导入数量" width="120"></el-table-column>
+        <el-table-column prop="failCount" label="导入失败数量" width="120"></el-table-column>
+        <el-table-column prop="remainingCount" label="券码剩余数量" width="120"></el-table-column>
+        <el-table-column prop="effectiveTime" label="生效时间" width="180"></el-table-column>
+        <el-table-column prop="expireTime" label="过期时间" width="180"></el-table-column>
+        <el-table-column prop="stopIssueTime" label="停止发放时间" width="180"></el-table-column>
+        <el-table-column label="文件处理状态" width="120">
+          <template #default="scope">
+            <el-tag :type="statusTagType(scope.row.fileStatus)" size="small">{{ scope.row.fileStatus }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="importTime" label="导入时间" width="180"></el-table-column>
+        <el-table-column prop="creator" label="创建人" width="100"></el-table-column>
+        <el-table-column label="操作" width="220" fixed="right">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button
+              v-if="Number(scope.row.remainingCount) > 0"
+              type="text"
+              size="small"
+              @click="handleRecycle(scope.row)"
+            >回收</el-button>
+            <el-button
+              v-if="Number(scope.row.failCount) > 0"
+              type="text"
+              size="small"
+              @click="handleExportFail(scope.row)"
+            >导出失败券码</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog
+      title="编辑券码批次"
+      v-model="editDialogVisible"
+      width="520px"
+      @closed="resetEditForm"
+    >
+      <el-form :model="editForm" label-width="120px" label-position="left">
+        <el-form-item label="生效时间" required>
+          <el-date-picker
+            v-model="editForm.effectiveTime"
+            type="datetime"
+            placeholder="请选择生效时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+        <el-form-item label="过期时间" required>
+          <el-date-picker
+            v-model="editForm.expireTime"
+            type="datetime"
+            placeholder="请选择过期时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+        <el-form-item label="停止发放时间" required>
+          <el-date-picker
+            v-model="editForm.stopIssueTime"
+            type="datetime"
+            placeholder="请选择停止发放时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span>
+          <el-button @click="editDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmEdit">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+    <el-dialog
+      title="券码批次回收"
+      v-model="recycleDialogVisible"
+      width="640px"
+      @closed="resetRecycleForm"
+    >
+      <div class="recycle-types">
+        <div
+          v-for="opt in recycleTypeOptions"
+          :key="opt.value"
+          :class="['recycle-type-item', { active: recycleForm.type === opt.value }]"
+          @click="recycleForm.type = opt.value"
+        >
+          <el-radio v-model="recycleForm.type" :label="opt.value">{{ opt.label }}</el-radio>
+        </div>
+      </div>
+
+      <el-form label-width="100px" label-position="left" style="margin-top: 24px;">
+        <el-form-item label="回收数量" required>
+          <el-input-number
+            v-model="recycleForm.amount"
+            :min="1"
+            :max="recycleMaxAmount"
+            :placeholder="`输入范围1-${recycleMaxAmount}`"
+            :controls-position="''"
+            style="width: 100%"
+          ></el-input-number>
+          <div class="recycle-tip">本批次{{ currentRecycleTypeLabel }}最多可回收数量:{{ recycleMaxAmount }}</div>
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <span class="dialog-footer-center">
+          <el-button @click="recycleDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmRecycle">提交</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+    <el-dialog title="导入券码" v-model="importDialogVisible" width="520px" @closed="resetImportForm">
+      <el-form :model="importForm" label-width="120px" label-position="left">
+        <el-form-item label="券码文件" required>
+          <el-upload
+            action=""
+            :auto-upload="false"
+            :file-list="importForm.files"
+            :on-change="handleFileChange"
+            :on-remove="handleFileRemove"
+            :limit="1"
+          >
+            <el-button>
+              <el-icon><Upload /></el-icon>选择文件
+            </el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持 .xlsx / .csv,仅可上传一个文件</div>
+            </template>
+          </el-upload>
+          <el-button type="text" @click="downloadTemplate">
+            <el-icon><Download /></el-icon>模板下载
+          </el-button>
+        </el-form-item>
+        <el-form-item label="生效时间" required>
+          <el-date-picker
+            v-model="importForm.effectiveTime"
+            type="datetime"
+            placeholder="请选择生效时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+        <el-form-item label="过期时间" required>
+          <el-date-picker
+            v-model="importForm.expireTime"
+            type="datetime"
+            placeholder="请选择过期时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+        <el-form-item label="停止发放时间" required>
+          <el-date-picker
+            v-model="importForm.stopIssueTime"
+            type="datetime"
+            placeholder="请选择停止发放时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="yyyy-MM-dd HH:mm:ss"
+            style="width: 100%"
+          ></el-date-picker>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span>
+          <el-button @click="importDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmImport">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+
+export default {
+  name: 'EquityProductCoupons',
+  data() {
+    return {
+      importDialogVisible: false,
+      editDialogVisible: false,
+      editingRow: null,
+      editForm: {
+        effectiveTime: '',
+        expireTime: '',
+        stopIssueTime: ''
+      },
+      recycleDialogVisible: false,
+      recycleRow: null,
+      recycleForm: {
+        type: 'unissued',
+        amount: undefined
+      },
+      recycleTypeOptions: [
+        { label: '未发放券码回收', value: 'unissued' },
+        { label: '已过期券码回收', value: 'expired' },
+        { label: '导错码券码回收', value: 'wrong' }
+      ],
+      importForm: {
+        files: [],
+        effectiveTime: '',
+        expireTime: '',
+        stopIssueTime: ''
+      },
+      batchList: [
+        {
+          importCount: 1000,
+          actualImportCount: 998,
+          failCount: 2,
+          remainingCount: 720,
+          effectiveTime: '2026-04-16 00:00:00',
+          expireTime: '2026-12-31 23:59:59',
+          stopIssueTime: '2026-11-30 23:59:59',
+          fileStatus: '处理成功',
+          importTime: '2026-04-15 10:32:18',
+          creator: '张三'
+        },
+        {
+          importCount: 500,
+          actualImportCount: 500,
+          failCount: 0,
+          remainingCount: 320,
+          effectiveTime: '2026-04-21 00:00:00',
+          expireTime: '2026-09-30 23:59:59',
+          stopIssueTime: '2026-08-31 23:59:59',
+          fileStatus: '处理成功',
+          importTime: '2026-04-20 14:15:42',
+          creator: '李四'
+        },
+        {
+          importCount: 800,
+          actualImportCount: 0,
+          failCount: 0,
+          remainingCount: 0,
+          effectiveTime: '2026-05-08 00:00:00',
+          expireTime: '2026-12-31 23:59:59',
+          stopIssueTime: '2026-11-30 23:59:59',
+          fileStatus: '处理中',
+          importTime: '2026-05-07 09:08:11',
+          creator: '王五'
+        },
+        {
+          importCount: 200,
+          actualImportCount: 0,
+          failCount: 200,
+          remainingCount: 0,
+          effectiveTime: '2026-05-02 00:00:00',
+          expireTime: '2026-10-31 23:59:59',
+          stopIssueTime: '2026-09-30 23:59:59',
+          fileStatus: '处理失败',
+          importTime: '2026-05-01 16:45:30',
+          creator: '赵六'
+        }
+      ]
+    }
+  },
+  computed: {
+    productId() {
+      return this.$route.params.id || ''
+    },
+    equityName() {
+      const map = {
+        'Q100001': '权益A',
+        'Q100002': '权益B',
+        'Q100003': '权益C',
+        'Q100004': '权益D',
+        'Q100005': '权益E'
+      }
+      return map[this.productId] || this.productId
+    },
+    currentRecycleTypeLabel() {
+      const opt = this.recycleTypeOptions.find(o => o.value === this.recycleForm.type)
+      if (!opt) return ''
+      return opt.label.replace('回收', '')
+    },
+    recycleMaxAmount() {
+      const row = this.recycleRow
+      if (!row) return 0
+      switch (this.recycleForm.type) {
+        case 'unissued': return Number(row.remainingCount) || 0
+        case 'expired': {
+          const ts = row.expireTime ? new Date(row.expireTime).getTime() : 0
+          return ts && ts < Date.now() ? Number(row.remainingCount) || 0 : 0
+        }
+        case 'wrong': return Number(row.failCount) || 0
+        default: return 0
+      }
+    }
+  },
+  methods: {
+    statusTagType(status) {
+      switch (status) {
+        case '处理成功': return 'success'
+        case '处理中': return 'warning'
+        case '处理失败': return 'danger'
+        default: return 'info'
+      }
+    },
+    openImportDialog() {
+      this.importDialogVisible = true
+    },
+    downloadTemplate() {
+      ElMessage.success('模板已开始下载')
+    },
+    handleFileChange(file) {
+      this.importForm.files = [file]
+    },
+    handleFileRemove() {
+      this.importForm.files = []
+    },
+    resetImportForm() {
+      this.importForm = {
+        files: [],
+        effectiveTime: '',
+        expireTime: '',
+        stopIssueTime: ''
+      }
+    },
+    confirmImport() {
+      if (!this.importForm.files.length) {
+        ElMessage.warning('请选择券码文件')
+        return
+      }
+      if (!this.importForm.effectiveTime) {
+        ElMessage.warning('请选择生效时间')
+        return
+      }
+      if (!this.importForm.expireTime) {
+        ElMessage.warning('请选择过期时间')
+        return
+      }
+      if (!this.importForm.stopIssueTime) {
+        ElMessage.warning('请选择停止发放时间')
+        return
+      }
+      this.batchList.unshift({
+        importCount: 0,
+        actualImportCount: 0,
+        failCount: 0,
+        remainingCount: 0,
+        effectiveTime: this.importForm.effectiveTime,
+        expireTime: this.importForm.expireTime,
+        stopIssueTime: this.importForm.stopIssueTime,
+        fileStatus: '处理中',
+        importTime: this.formatNow(),
+        creator: '当前用户'
+      })
+      ElMessage.success('券码已提交,正在处理中')
+      this.importDialogVisible = false
+    },
+    formatNow() {
+      const d = new Date()
+      const pad = n => String(n).padStart(2, '0')
+      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+    },
+    handleEdit(row) {
+      this.editingRow = row
+      this.editForm = {
+        effectiveTime: row.effectiveTime || '',
+        expireTime: row.expireTime || '',
+        stopIssueTime: row.stopIssueTime || ''
+      }
+      this.editDialogVisible = true
+    },
+    resetEditForm() {
+      this.editingRow = null
+      this.editForm = { effectiveTime: '', expireTime: '', stopIssueTime: '' }
+    },
+    confirmEdit() {
+      if (!this.editForm.effectiveTime) {
+        ElMessage.warning('请选择生效时间')
+        return
+      }
+      if (!this.editForm.expireTime) {
+        ElMessage.warning('请选择过期时间')
+        return
+      }
+      if (!this.editForm.stopIssueTime) {
+        ElMessage.warning('请选择停止发放时间')
+        return
+      }
+      if (this.editingRow) {
+        this.editingRow.effectiveTime = this.editForm.effectiveTime
+        this.editingRow.expireTime = this.editForm.expireTime
+        this.editingRow.stopIssueTime = this.editForm.stopIssueTime
+        ElMessage.success('保存成功')
+      }
+      this.editDialogVisible = false
+    },
+    handleRecycle(row) {
+      this.recycleRow = row
+      this.recycleForm = { type: 'unissued', amount: undefined }
+      this.recycleDialogVisible = true
+    },
+    handleExportFail(row) {
+      ElMessage.success(`已开始导出失败券码(${row.failCount} 条)`)
+    },
+    resetRecycleForm() {
+      this.recycleRow = null
+      this.recycleForm = { type: 'unissued', amount: undefined }
+    },
+    confirmRecycle() {
+      const max = this.recycleMaxAmount
+      const amount = Number(this.recycleForm.amount)
+      if (!amount || amount < 1) {
+        ElMessage.warning('请输入回收数量')
+        return
+      }
+      if (amount > max) {
+        ElMessage.error(`回收数量不能超过 ${max}`)
+        return
+      }
+      if (this.recycleRow && this.recycleForm.type === 'unissued') {
+        this.recycleRow.remainingCount = Math.max(0, Number(this.recycleRow.remainingCount) - amount)
+      }
+      ElMessage.success('回收成功')
+      this.recycleDialogVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.equity-product-coupons {
+  padding: 20px;
+}
+
+.header-title {
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.product-id {
+  font-size: 14px;
+  font-weight: normal;
+  color: #909399;
+  margin-left: 16px;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.toolbar-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+  margin-left: auto;
+}
+
+.recycle-types {
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+}
+
+.recycle-type-item {
+  flex: 1;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  padding: 12px 16px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.recycle-type-item.active {
+  border-color: #409eff;
+  background: #ecf5ff;
+}
+
+.recycle-type-item >>> .el-radio__label {
+  font-size: 14px;
+  color: #303133;
+}
+
+.recycle-tip {
+  margin-top: 8px;
+  font-size: 13px;
+  color: #909399;
+}
+
+.dialog-footer-center {
+  display: flex;
+  justify-content: center;
+  gap: 24px;
+}
+</style>

+ 466 - 0
pc-v3/src/views/EquityProducts.vue

@@ -0,0 +1,466 @@
+<template>
+  <div class="equity-products">
+    <el-card>
+      <!-- 搜索区域 -->
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="80px" inline>
+            <el-form-item label="权益名称:">
+              <el-input v-model="searchForm.equityName" placeholder="请输入" style="width: 150px"></el-input>
+            </el-form-item>
+            <el-form-item label="权益编号:">
+              <el-input v-model="searchForm.productId" placeholder="请输入" style="width: 150px"></el-input>
+            </el-form-item>
+            <el-form-item label="商品状态:">
+              <el-select v-model="searchForm.shelfStatus" placeholder="全部" clearable style="width: 130px">
+                <el-option label="未上架" value="未上架"></el-option>
+                <el-option label="已上架" value="已上架"></el-option>
+              </el-select>
+            </el-form-item>
+            <el-form-item label="审核状态:">
+              <el-select v-model="searchForm.auditStatus" placeholder="全部" clearable style="width: 150px">
+                <el-option label="草稿" value="草稿"></el-option>
+                <el-option label="上架待审核" value="上架待审核"></el-option>
+                <el-option label="下架待审核" value="下架待审核"></el-option>
+                <el-option label="审核通过" value="审核通过"></el-option>
+              </el-select>
+            </el-form-item>
+            <div class="button-group" style="margin-left: 20px;">
+              <el-button type="primary" @click="loadData">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div style="margin-top: 15px;">
+          <el-button type="primary" @click="createProduct">
+            <el-icon><Plus /></el-icon>添加权益
+          </el-button>
+        </div>
+        </div>
+      </template>
+
+      <!-- 表格区域 -->
+      <el-table :data="tableData" stripe style="width: 100%">
+        <el-table-column label="操作" width="240" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleView(scope.row)">查看</el-button>
+            <el-button
+              v-if="canEdit(scope.row)"
+              type="text"
+              size="small"
+              @click="handleEdit(scope.row)"
+            >编辑</el-button>
+            <el-button
+              v-if="canRequestOffshelf(scope.row)"
+              type="text"
+              size="small"
+              @click="handleOffshelf(scope.row)"
+            >下架</el-button>
+            <el-popover
+              placement="bottom"
+              trigger="click"
+              :ref="`morePopover-${scope.$index}`"
+            >
+              <div class="more-actions-menu">
+                <el-popover
+                  placement="left"
+                  trigger="click"
+                  width="240"
+                  @show="onAdjustOrderShow(scope.$index)"
+                  :ref="`adjustPopover-${scope.$index}`"
+                >
+                  <div class="adjust-order-form">
+                    <div class="adjust-order-title">调整显示顺序</div>
+                    <div class="adjust-order-tip">移动端权益首页和列表页按照该顺序展示</div>
+                    <div class="adjust-order-input">
+                      <span>调整到第</span>
+                      <el-input-number
+                        v-model.number="adjustOrderPosition"
+                        :min="1"
+                        :max="tableData.length"
+                        size="small"
+                        :controls="false"
+                        style="width: 70px;margin: 0 6px;"
+                      />
+                      <span>位</span>
+                    </div>
+                    <div class="adjust-order-actions">
+                      <el-button size="small" @click="closeAdjustOrder(scope.$index)">取消</el-button>
+                      <el-button size="small" type="primary" @click="confirmAdjustOrder(scope.$index)">确定</el-button>
+                    </div>
+                  </div>
+                  <template #reference>
+                    <div class="more-actions-item">调整显示顺序</div>
+                  </template>
+                </el-popover>
+                <div
+                  class="more-actions-item"
+                  @click="handleCopyFromMore(scope.row, scope.$index)"
+                >复制</div>
+                <div
+                  class="more-actions-item"
+                  @click="openLinkDialogFromMore(scope.row, scope.$index)"
+                >生成链接</div>
+                <div
+                  v-if="scope.row.source === '银行自有' && scope.row.productType === '券码'"
+                  class="more-actions-item"
+                  @click="handleImportCouponsFromMore(scope.row, scope.$index)"
+                >导入券码</div>
+                <div
+                  v-if="scope.row.source === '银行自有' && scope.row.productType === '券码'"
+                  class="more-actions-item"
+                  @click="handleManageCouponsFromMore(scope.row, scope.$index)"
+                >券码管理</div>
+                <div
+                  v-if="canDelete(scope.row)"
+                  class="more-actions-item more-actions-item-danger"
+                  @click="handleDeleteFromMore(scope.row, scope.$index)"
+                >删除</div>
+              </div>
+              <template #reference>
+                <el-button type="text" size="small" style="margin-left: 8px;">
+                  <el-icon><More /></el-icon>
+                </el-button>
+              </template>
+            </el-popover>
+          </template>
+        </el-table-column>
+        <el-table-column prop="equityName" label="权益名称" width="140" show-overflow-tooltip></el-table-column>
+        <el-table-column label="商品状态" width="100">
+          <template #default="scope">
+            <el-tag :type="getShelfTagType(scope.row.shelfStatus)" size="small">{{ scope.row.shelfStatus }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="审核状态" width="120">
+          <template #default="scope">
+            <el-tag :type="getAuditTagType(scope.row.auditStatus)" size="small">{{ scope.row.auditStatus }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="source" label="权益来源" width="100"></el-table-column>
+        <el-table-column prop="jumpLink" label="外部权益链接" width="120"></el-table-column>
+        <el-table-column prop="jumpMethod" label="是否有详情页" width="120"></el-table-column>
+        <el-table-column prop="productId" label="权益编号" width="100"></el-table-column>
+        <el-table-column label="显示页面" min-width="220">
+          <template #default="scope">
+            <template v-if="visiblePages(scope.row).length">
+              <el-tag
+                v-for="page in visiblePages(scope.row)"
+                :key="page"
+                size="small"
+                style="margin-right: 4px;"
+              >{{ page }}</el-tag>
+            </template>
+            <span v-else class="empty-text">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator" label="创建人" width="100"></el-table-column>
+        <el-table-column prop="createTime" label="创建时间" width="170"></el-table-column>
+        <el-table-column prop="reviewer" label="审核人" width="100"></el-table-column>
+        <el-table-column prop="reviewTime" label="审核时间" width="170"></el-table-column>
+      </el-table>
+
+      <equity-link-dialog
+        v-model:visible="linkDialogVisible"
+        :id="linkTargetId"
+        path-name="equity/debit"
+        title="生成借记卡权益链接"
+      ></equity-link-dialog>
+
+      <!-- 分页 -->
+      <div class="pagination-section">
+        <span>Total {{ tableData.length }}</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          :page-size="10"
+          :page-sizes="[10, 20, 50]"
+          :total="tableData.length"
+        ></el-pagination>
+      </div>
+    </el-card>
+
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import request from '@/utils/request'
+import EquityLinkDialog from '@/components/EquityLinkDialog.vue'
+
+export default {
+  name: 'EquityProducts',
+  components: { EquityLinkDialog },
+  data() {
+    return {
+      searchForm: {
+        equityName: '',
+        productId: '',
+        shelfStatus: '',
+        auditStatus: ''
+      },
+      tableData: [],
+      adjustOrderPosition: 1,
+      linkDialogVisible: false,
+      linkTargetId: ''
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  activated() {
+    this.loadData()
+  },
+  watch: {
+    $route(to) {
+      if (to.path === '/customization/hsbc/equity-products') {
+        this.loadData()
+      }
+    }
+  },
+  methods: {
+    loadData() {
+      const params = new URLSearchParams()
+      if (this.searchForm.equityName) params.append('equityName', this.searchForm.equityName)
+      if (this.searchForm.productId) params.append('productId', this.searchForm.productId)
+      if (this.searchForm.shelfStatus) params.append('shelfStatus', this.searchForm.shelfStatus)
+      if (this.searchForm.auditStatus) params.append('auditStatus', this.searchForm.auditStatus)
+      const qs = params.toString()
+      request.get('/api/equity/list' + (qs ? `?${qs}` : '')).then(res => {
+        this.tableData = res.data || []
+      }).catch(() => {})
+    },
+    handleReset() {
+      this.searchForm = { equityName: '', productId: '', shelfStatus: '', auditStatus: '' }
+      this.loadData()
+    },
+    // 按钮可见性
+    canEdit(row) {
+      return row.shelfStatus === '未上架' && row.auditStatus === '草稿'
+    },
+    canRequestOffshelf(row) {
+      return row.shelfStatus === '已上架' && row.auditStatus === '审核通过'
+    },
+    canDelete(row) {
+      return row.shelfStatus === '未上架' && (row.auditStatus === '草稿' || row.auditStatus === '审核通过')
+    },
+    // Tag 样式
+    getShelfTagType(s) {
+      if (s === '已上架') return 'success'
+      return 'info'
+    },
+    getAuditTagType(s) {
+      if (s === '审核通过') return 'success'
+      if (s === '草稿') return 'info'
+      if (s === '上架待审核') return ''
+      if (s === '下架待审核') return 'danger'
+      return ''
+    },
+    visiblePages(row) {
+      const pages = []
+      if (row.showOnHome === '显示') pages.push('权益首页/列表页')
+      if (row.showOnAssembly === '显示' && Array.isArray(row.assemblyPages)) {
+        pages.push(...row.assemblyPages)
+      }
+      return pages
+    },
+    // 路由跳转
+    createProduct() {
+      this.$router.push('/customization/hsbc/equity-products/add')
+    },
+    handleView(row) {
+      this.$router.push(`/customization/hsbc/equity-products/view/${row.productId}`)
+    },
+    handleEdit(row) {
+      this.$router.push(`/customization/hsbc/equity-products/edit/${row.productId}`)
+    },
+    // 下架(提交下架审核)
+    handleOffshelf(row) {
+      ElMessageBox.confirm(`确定提交"${row.equityName}"的下架审核吗?审核通过后才会下架。`, '提示', {
+        confirmButtonText: '提交',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        request.post('/api/equity/request-offshelf', {
+          productId: row.productId
+        }).then(() => {
+          ElMessage.success('已提交下架审核')
+          this.loadData()
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    // popover 工具方法
+    getPopoverInstance(name) {
+      const ref = this.$refs[name]
+      if (!ref) return null
+      return Array.isArray(ref) ? ref[0] : ref
+    },
+    onAdjustOrderShow(index) {
+      this.adjustOrderPosition = index + 1
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+    },
+    closeAdjustOrder(index) {
+      const adjustRef = this.getPopoverInstance(`adjustPopover-${index}`)
+      if (adjustRef) adjustRef.hide()
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+    },
+    confirmAdjustOrder(index) {
+      const target = Number(this.adjustOrderPosition)
+      if (!target || target < 1 || target > this.tableData.length) {
+        ElMessage.warning('请输入有效的顺序位置')
+        return
+      }
+      const targetIndex = target - 1
+      if (targetIndex !== index) {
+        const moved = this.tableData.splice(index, 1)[0]
+        this.tableData.splice(targetIndex, 0, moved)
+      }
+      ElMessage.success('顺序已调整')
+      this.closeAdjustOrder(index)
+    },
+    showLinkDialog(row) {
+      this.linkTargetId = row.productId
+      this.linkDialogVisible = true
+    },
+    openLinkDialogFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+      this.showLinkDialog(row)
+    },
+    handleCopyFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+      this.$router.push({
+        path: '/customization/hsbc/equity-products/add',
+        query: { copyFrom: row.productId }
+      })
+    },
+    handleManageCouponsFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+      window.open('https://uat.leagpoint.com/sipAdmin/coupon/inboundCouponList', '_blank')
+    },
+    handleImportCouponsFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+      window.open('https://uat.leagpoint.com/sipAdmin/coupon/importCoupon', '_blank')
+    },
+    handleDeleteFromMore(row, index) {
+      const moreRef = this.getPopoverInstance(`morePopover-${index}`)
+      if (moreRef) moreRef.hide()
+      ElMessageBox.confirm(`确定删除权益"${row.equityName}"吗?`, '提示', {
+        confirmButtonText: '删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        request.post('/api/equity/delete', { productId: row.productId }).then(() => {
+          ElMessage.success('删除成功')
+          this.loadData()
+        }).catch(() => {})
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.equity-products {
+  padding: 0;
+}
+
+.search-section {
+  display: flex;
+  flex-direction: column;
+}
+
+.search-row {
+  margin-bottom: 15px;
+}
+
+.search-row .el-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0;
+}
+
+.search-row .el-form-item {
+  margin-right: 0;
+  margin-bottom: 0;
+}
+
+.button-group {
+  display: flex;
+  gap: 5px;
+}
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+
+.empty-text {
+  color: #c0c4cc;
+}
+</style>
+
+<style>
+.more-actions-menu {
+  display: flex;
+  flex-direction: column;
+  margin: -12px;
+}
+
+.more-actions-item {
+  padding: 8px 16px;
+  cursor: pointer;
+  font-size: 14px;
+  color: #606266;
+}
+
+.more-actions-item:hover {
+  background: #f5f7fa;
+  color: #409eff;
+}
+
+.more-actions-item-danger:hover {
+  color: #f56c6c;
+}
+
+.adjust-order-form {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.adjust-order-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.adjust-order-tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+  padding: 8px 10px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+.adjust-order-input {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  color: #606266;
+}
+
+.adjust-order-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
+</style>

+ 136 - 0
pc-v3/src/views/Login.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="login-page">
+    <div class="login-box">
+      <div class="login-header">
+        <el-icon><Monitor /></el-icon>
+        <span>管理后台</span>
+      </div>
+      <p class="login-tip">请使用账号密码登录</p>
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        @submit.prevent="handleSubmit"
+      >
+        <el-form-item prop="username">
+          <el-input
+            v-model="form.username"
+            placeholder="请输入账号"
+            :prefix-icon="User"
+            autocomplete="username"
+          />
+        </el-form-item>
+        <el-form-item prop="password">
+          <el-input
+            v-model="form.password"
+            type="password"
+            placeholder="请输入密码"
+            :prefix-icon="Lock"
+            autocomplete="current-password"
+            show-password
+            @keyup.enter="handleSubmit"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="loading"
+            class="login-btn"
+            native-type="submit"
+          >
+            登录
+          </el-button>
+        </el-form-item>
+      </el-form>
+      <p class="login-hint">演示账号:admin / 123456</p>
+    </div>
+  </div>
+</template>
+
+<script>
+import { markRaw } from 'vue'
+import { ElMessage } from 'element-plus'
+import { User, Lock } from '@element-plus/icons-vue'
+import request from '@/utils/request'
+
+export default {
+  name: 'Login',
+  data() {
+    return {
+      loading: false,
+      form: { username: '', password: '' },
+      rules: {
+        username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
+        password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
+      },
+      User: markRaw(User),
+      Lock: markRaw(Lock)
+    }
+  },
+  methods: {
+    handleSubmit() {
+      this.$refs.formRef.validate(async (valid) => {
+        if (!valid) return
+        this.loading = true
+        try {
+          const res = await request.post('/api/login', this.form)
+          localStorage.setItem('token', res.data.token)
+          localStorage.setItem('username', res.data.username)
+          ElMessage.success('登录成功')
+          const redirect = this.$route.query.redirect || '/'
+          this.$router.replace(redirect)
+        } finally {
+          this.loading = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.login-page {
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #304156 0%, #1f2d3d 100%);
+}
+
+.login-box {
+  width: 380px;
+  padding: 36px 32px 24px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
+}
+
+.login-header {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  color: #304156;
+  font-size: 22px;
+  font-weight: bold;
+}
+.login-header .el-icon { font-size: 26px; }
+
+.login-tip {
+  margin: 10px 0 24px;
+  color: #909399;
+  text-align: center;
+  font-size: 13px;
+}
+
+.login-btn {
+  width: 100%;
+}
+
+.login-hint {
+  margin: 8px 0 0;
+  color: #c0c4cc;
+  text-align: center;
+  font-size: 12px;
+}
+</style>

+ 764 - 0
pc-v3/src/views/PageAssembly.vue

@@ -0,0 +1,764 @@
+<template>
+  <div class="page-assembly">
+    <!-- ==================== 列表视图 ==================== -->
+    <template v-if="viewState === 'list'">
+      <el-card>
+        <template #header>
+          <div class="search-section">
+          <div class="search-row">
+            <el-form :model="searchForm" label-width="80px" inline>
+              <el-form-item label="页面ID:">
+                <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width:160px" />
+              </el-form-item>
+              <el-form-item label="页面名称:">
+                <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width:160px" />
+              </el-form-item>
+              <div class="button-group">
+                <el-button type="primary" @click="handleSearch">查询</el-button>
+                <el-button @click="handleReset">重置</el-button>
+              </div>
+            </el-form>
+          </div>
+          <div class="action-row">
+            <el-button type="primary" @click="handleCreate">
+              <el-icon><Plus /></el-icon>新建组装页
+            </el-button>
+          </div>
+          </div>
+        </template>
+
+        <el-table :data="tableData" stripe border style="width:100%">
+          <el-table-column label="操作" width="200" fixed="left">
+            <template #default="scope">
+              <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+              <el-button type="text" size="small" @click="showLinkDialog(scope.row.id)">链接</el-button>
+              <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+          <el-table-column prop="id" label="页面ID" width="120" align="center" />
+          <el-table-column prop="name" label="组装页名称" min-width="200" show-overflow-tooltip />
+          <el-table-column label="发布状态" width="100" align="center">
+            <template #default="scope">
+              <el-tag :type="scope.row.status === 'published' ? 'success' : 'info'" size="small">
+                {{ scope.row.status === 'published' ? '已上架' : '草稿' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
+          <el-table-column prop="updateTime" label="更新时间" width="180" align="center" />
+        </el-table>
+      </el-card>
+    </template>
+
+    <!-- ==================== 编辑视图 ==================== -->
+    <template v-else>
+      <!-- 操作栏 -->
+      <div class="toolbar">
+        <div class="toolbar-left">
+          <el-button size="small" @click="backToList">
+            <el-icon><ArrowLeft /></el-icon>返回
+          </el-button>
+          <h2 class="toolbar-title">{{ isNew ? '新建组装页' : '编辑组装页' }}</h2>
+          <el-tag v-if="editData.status === 'published' && !isModified" type="success" size="small" effect="plain">正式线上环境</el-tag>
+          <el-tag v-else-if="isModified" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
+          <el-tag v-else size="info" effect="plain">草稿 (未发布)</el-tag>
+        </div>
+        <div class="toolbar-right">
+          <el-button size="small" @click="showLinkDialog(editData.id)">
+            <el-icon><Link /></el-icon>获取链接
+          </el-button>
+          <el-button size="small" type="primary" plain @click="handleSaveAssembly('draft')">保存草稿</el-button>
+          <el-button size="small" type="primary" @click="handleSaveAssembly('publish')">发布上线</el-button>
+        </div>
+      </div>
+
+      <!-- 基本信息 -->
+      <el-card class="section-card" shadow="hover">
+        <div class="basic-info">
+          <div class="info-item">
+            <span class="info-label">页面 ID:</span>
+            <el-tag size="small" effect="plain">{{ editData.id || '保存后生成' }}</el-tag>
+          </div>
+          <div class="info-item" style="flex:1">
+            <span class="info-label">组装页名称 <span style="color:#F56C6C">*</span></span>
+            <el-input v-model="editData.name" placeholder="请为该页面设置一个名称 (必填)" size="small" @input="isModified = true" />
+          </div>
+        </div>
+      </el-card>
+
+      <!-- 模块列表 -->
+      <div v-for="(block, bIdx) in editData.blocks" :key="block.id" class="block-card">
+        <!-- 模块操作栏 -->
+        <div class="block-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="editData.blocks.length" size="small" controls-position="right" style="width:100px" />
+                <el-button size="small" type="primary" @click="moveBlock(bIdx, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <template #reference>
+              <el-button size="small" @click="sortTarget = bIdx + 1">
+                <el-icon><Sort /></el-icon>排序
+              </el-button>
+            </template>
+          </el-popover>
+          <el-button v-if="block.type !== 'privilege'" size="small" @click="duplicateBlock(bIdx)">
+            <el-icon><CopyDocument /></el-icon>复制
+          </el-button>
+          <el-button size="small" type="danger" @click="removeBlock(bIdx)">
+            <el-icon><Delete /></el-icon>删除
+          </el-button>
+        </div>
+
+        <!-- 图片模块 -->
+        <template v-if="block.type === 'image'">
+          <div class="block-header"><el-icon><PictureRounded /></el-icon> 图片模块</div>
+          <div class="block-body">
+            <div class="image-upload-area">
+              <div class="upload-box upload-box-wide" @click="triggerBlockUpload(bIdx)">
+                <img v-if="block.data.imageUrl" :src="block.data.imageUrl" class="preview-img" />
+                <div v-else class="upload-placeholder">
+                  <el-icon><Picture /></el-icon><span>未上传图片</span>
+                </div>
+                <div class="upload-overlay"><el-icon><Upload /></el-icon><span>点击上传图片</span></div>
+              </div>
+              <div class="upload-tip">建议宽度:<b>750px</b><br/>高度不限,系统将按比例自适应展示</div>
+              <input :ref="'blockUpload' + bIdx" type="file" accept="image/*" style="display:none" @change="onBlockImageUpload($event, bIdx)" />
+            </div>
+          </div>
+        </template>
+
+        <!-- 权益商品模块 -->
+        <template v-else-if="block.type === 'privilege'">
+          <div class="block-header"><el-icon><Grid /></el-icon> 权益商品模块 <span class="block-hint">(当前页面仅限添加一个)</span></div>
+          <div class="block-body">
+            <el-form label-width="100px" style="margin-bottom:16px;border-bottom:1px solid #ebeef5;padding-bottom:16px">
+              <el-form-item label="可配置大标题">
+                <el-input v-model="block.data.title" style="width:50%" @input="isModified = true" />
+              </el-form-item>
+            </el-form>
+            <div v-for="(pItem, pIdx) in block.data.items" :key="pIdx" class="privilege-row">
+              <el-tag size="small" effect="plain" class="privilege-no">NO. {{ pIdx + 1 }}</el-tag>
+              <div class="privilege-icon"><el-icon><Document /></el-icon></div>
+              <div class="privilege-info">
+                <div class="privilege-title">{{ pItem.title }}</div>
+                <div class="privilege-desc">{{ pItem.desc }}</div>
+              </div>
+              <el-popover placement="bottom" trigger="click" width="180">
+                <div>
+                  <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                  <div style="display:flex;gap:8px">
+                    <el-input-number v-model="sortTarget" :min="1" :max="block.data.items.length" size="small" controls-position="right" style="width:100px" />
+                    <el-button size="small" type="primary" @click="movePrivilegeItem(bIdx, pIdx, sortTarget - 1)">确定</el-button>
+                  </div>
+                </div>
+                <template #reference>
+                  <el-button size="small" circle @click="sortTarget = pIdx + 1">
+                    <el-icon><Sort /></el-icon>
+                  </el-button>
+                </template>
+              </el-popover>
+            </div>
+          </div>
+        </template>
+
+        <!-- 图文模块 (富文本) -->
+        <template v-else-if="block.type === 'richtext'">
+          <div class="block-header"><el-icon><Notebook /></el-icon> 图文模块</div>
+          <div class="block-body">
+            <quill-editor
+              v-model:content="block.data.content"
+              content-type="html"
+              :options="quillOptions"
+              @change="onQuillChange"
+            />
+          </div>
+        </template>
+
+        <!-- 按钮模块 -->
+        <template v-else-if="block.type === 'buttonGroup'">
+          <div class="block-header button-module-header">
+            <span class="button-module-title"><el-icon><Pointer /></el-icon> 按钮模块</span>
+            <el-button class="add-button-btn" type="primary" plain size="small" @click="addButton(bIdx)">
+              <el-icon><CirclePlus /></el-icon>添加按钮
+            </el-button>
+          </div>
+          <div class="block-body">
+            <div v-if="!block.data.buttons || block.data.buttons.length === 0" class="empty-tip">暂无按钮</div>
+            <div v-for="(btn, btnIdx) in (block.data.buttons || [])" :key="btnIdx" class="button-config-item">
+              <div class="btn-index">BTN {{ btnIdx + 1 }}</div>
+              <div class="btn-config-body">
+                <el-form class="button-form" label-position="top">
+                  <el-row :gutter="16">
+                    <el-col :span="8">
+                      <el-form-item label="按钮文案">
+                        <el-input v-model="btn.text" size="small" @input="isModified = true" />
+                      </el-form-item>
+                    </el-col>
+                    <el-col :span="8">
+                      <el-form-item label="点击动作">
+                        <el-select v-model="btn.actionType" size="small" @change="handleButtonActionChange(btn)">
+                          <el-option label="跳转链接" value="link" />
+                          <el-option label="弹窗提示" value="dialog" />
+                        </el-select>
+                      </el-form-item>
+                    </el-col>
+                    <el-col :span="8">
+                      <el-form-item label="按钮样式">
+                        <el-select v-model="btn.style" size="small" @change="isModified = true">
+                          <el-option label="突出显示(红色)" value="primary" />
+                          <el-option label="次要操作(白色)" value="secondary" />
+                        </el-select>
+                      </el-form-item>
+                    </el-col>
+                  </el-row>
+                  <!-- 跳转链接 -->
+                  <el-form-item v-if="btn.actionType === 'link'" label="跳转链接 *">
+                    <el-input v-model="btn.link" size="small" placeholder="必填,输入跳转URL" @input="isModified = true">
+                    </el-input>
+                  </el-form-item>
+                  <!-- 弹窗配置 -->
+                  <div v-if="btn.actionType === 'dialog' && btn.dialog" class="dialog-config">
+                    <div class="dialog-config-title"><el-icon><Document /></el-icon> 弹窗内容配置(包含取消、确认按钮)</div>
+                    <el-form-item label="弹窗标题">
+                      <el-input v-model="btn.dialog.title" size="small" placeholder="弹窗标题" @input="isModified = true" />
+                    </el-form-item>
+                    <el-form-item label="弹窗描述">
+                      <el-input v-model="btn.dialog.desc" type="textarea" :rows="2" size="small" placeholder="弹窗正文提示..." @input="isModified = true" />
+                    </el-form-item>
+                    <el-row :gutter="16">
+                      <el-col :span="12">
+                        <el-form-item label="右侧确认按钮文案">
+                          <el-input v-model="btn.dialog.confirmText" size="small" placeholder="确认" @input="isModified = true" />
+                        </el-form-item>
+                      </el-col>
+                      <el-col :span="12">
+                        <el-form-item label="确认按钮跳转链接">
+                          <el-input v-model="btn.dialog.confirmLink" size="small" placeholder="选填,无链接则仅关闭弹窗" @input="isModified = true" />
+                        </el-form-item>
+                      </el-col>
+                    </el-row>
+                  </div>
+                </el-form>
+              </div>
+              <div class="btn-config-actions">
+                <el-popover placement="bottom" trigger="click" width="180">
+                  <div>
+                    <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                    <div style="display:flex;gap:8px">
+                      <el-input-number v-model="sortTarget" :min="1" :max="block.data.buttons.length" size="small" controls-position="right" style="width:100px" />
+                      <el-button size="small" type="primary" @click="moveButton(bIdx, btnIdx, sortTarget - 1)">确定</el-button>
+                    </div>
+                  </div>
+                  <template #reference>
+                    <el-button size="small" circle @click="sortTarget = btnIdx + 1">
+                      <el-icon><Sort /></el-icon>
+                    </el-button>
+                  </template>
+                </el-popover>
+                <el-button size="small" type="danger" circle @click="removeButton(bIdx, btnIdx)">
+                  <el-icon><Delete /></el-icon>
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <!-- 添加模块按钮 -->
+      <div class="add-block-bar">
+        <el-button type="text" @click="addBlock('image')">
+          <el-icon><PictureRounded /></el-icon>图片模块
+        </el-button>
+        <el-button v-if="!hasPrivilegeBlock" type="text" @click="addBlock('privilege')">
+          <el-icon><Grid /></el-icon>权益商品模块
+        </el-button>
+        <el-button type="text" @click="addBlock('richtext')">
+          <el-icon><Notebook /></el-icon>图文模块
+        </el-button>
+        <el-button type="text" @click="addBlock('buttonGroup')">
+          <el-icon><Pointer /></el-icon>按钮模块
+        </el-button>
+      </div>
+    </template>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" v-model="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+            <el-option v-for="opt in campaignOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+            <el-option v-for="opt in channelOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="linkTargetPublished" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5链接</span>
+          <el-input :value="assemblyProdH5" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyProdH5)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序链接</span>
+          <el-input :value="assemblyProdApp" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyProdApp)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="assemblyTestUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyTestUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import request from '@/utils/request'
+import { fetchLinkOptions } from '@/utils/linkOptions'
+
+let blockIdCounter = 0
+function genBlockId() {
+  return 'b_' + (++blockIdCounter) + '_' + Math.random().toString(36).substr(2, 6)
+}
+
+export default {
+  name: 'PageAssembly',
+  data() {
+    return {
+      viewState: 'list',
+      tableData: [],
+      searchForm: { id: '', name: '' },
+      editData: this.createEmptyAssembly(),
+      isNew: true,
+      isModified: false,
+      quillReady: false,
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' },
+      campaignOptions: [],
+      channelOptions: [],
+      linkTargetId: '',
+      linkTargetPublished: false,
+      quillOptions: {
+        placeholder: '请输入图文内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      }
+    }
+  },
+  computed: {
+    hasPrivilegeBlock() {
+      return this.editData.blocks.some(b => b.type === 'privilege')
+    },
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '&' + params.join('&') : ''
+    },
+    assemblyProdH5() {
+      return `https://hsbc.com/assembly?id=${this.linkTargetId}${this.linkQueryStr}`
+    },
+    assemblyProdApp() {
+      return `hsbc-app://assembly?id=${this.linkTargetId}${this.linkQueryStr}`
+    },
+    assemblyTestUrl() {
+      return `https://test.hsbc.com/assembly?id=${this.linkTargetId}&draft=1${this.linkQueryStr}`
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    createDefaultButtonDialog() {
+      return { title: '', desc: '', confirmText: '确认', confirmLink: '' }
+    },
+    createDefaultButton(overrides = {}) {
+      return {
+        text: '新按钮',
+        link: '',
+        style: 'secondary',
+        actionType: 'link',
+        ...overrides,
+        dialog: {
+          ...this.createDefaultButtonDialog(),
+          ...(overrides.dialog || {})
+        }
+      }
+    },
+    ensureReactiveField(target, key, value) {
+      if (Object.prototype.hasOwnProperty.call(target, key)) {
+        target[key] = value
+      } else {
+        target[key] = value
+      }
+    },
+    normalizeButton(btn) {
+      if (!btn) return
+      if (!btn.text) this.ensureReactiveField(btn, 'text', '')
+      if (!btn.style) this.ensureReactiveField(btn, 'style', 'secondary')
+      if (!btn.actionType) this.ensureReactiveField(btn, 'actionType', 'link')
+      if (!Object.prototype.hasOwnProperty.call(btn, 'link')) this.ensureReactiveField(btn, 'link', '')
+      if (!btn.dialog) {
+        this.ensureReactiveField(btn, 'dialog', this.createDefaultButtonDialog())
+      } else {
+        const defaults = this.createDefaultButtonDialog()
+        Object.keys(defaults).forEach(key => {
+          if (!Object.prototype.hasOwnProperty.call(btn.dialog, key)) {
+            btn.dialog[key] = defaults[key]
+          }
+        })
+      }
+    },
+    normalizeAssemblyData(data) {
+      const blocks = data.blocks || []
+      blocks.forEach(block => {
+        if (block.type !== 'buttonGroup') return
+        if (!block.data) block.data = { buttons: [] }
+        if (!block.data.buttons) block.data.buttons = []
+        block.data.buttons.forEach(btn => this.normalizeButton(btn))
+      })
+      return data
+    },
+    createEmptyAssembly() {
+      return {
+        id: '', name: '', status: 'draft',
+        blocks: [
+          { type: 'image', id: genBlockId(), data: { imageUrl: '' } },
+          { type: 'privilege', id: genBlockId(), data: { title: '权益标题', items: [{ id: '1', title: '权益名称', desc: '权益简介' }] } },
+          { type: 'richtext', id: genBlockId(), data: { content: '<p>组装页富文本区块内容...</p>' } },
+          { type: 'buttonGroup', id: genBlockId(), data: { buttons: [this.createDefaultButton({ text: '点击配置', style: 'primary' })] } }
+        ]
+      }
+    },
+    async fetchList() {
+      const res = await request.get('/api/assembly/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    async loadLinkOptions() {
+      const { campaignOptions, channelOptions } = await fetchLinkOptions()
+      this.campaignOptions = campaignOptions
+      this.channelOptions = channelOptions
+    },
+    handleSearch() {
+      this.fetchList()
+    },
+    handleReset() {
+      this.searchForm = { id: '', name: '' }
+      this.fetchList()
+    },
+    handleCreate() {
+      this.editData = this.createEmptyAssembly()
+      this.isNew = true
+      this.isModified = true
+      this.quillReady = false
+      this.viewState = 'edit'
+      this.$nextTick(() => { this.quillReady = true })
+    },
+    handleEdit(row) {
+      this.editData = this.normalizeAssemblyData(JSON.parse(JSON.stringify(row)))
+      this.isNew = false
+      this.isModified = false
+      this.quillReady = false
+      this.viewState = 'edit'
+      this.$nextTick(() => { this.quillReady = true })
+    },
+    async handleDelete(row) {
+      await ElMessageBox.confirm('确认要删除该组装页吗?此操作不可恢复。', '提示', { type: 'warning' })
+      await request.post('/api/assembly/delete', { id: row.id })
+      ElMessage.success('删除成功')
+      this.fetchList()
+    },
+    backToList() {
+      this.viewState = 'list'
+      this.fetchList()
+    },
+    async handleSaveAssembly(type) {
+      const data = this.editData
+      this.normalizeAssemblyData(data)
+      let hasError = false
+      data.blocks.forEach(block => {
+        if (block.type === 'buttonGroup') {
+          block.data.buttons.forEach(btn => {
+            if (btn.actionType === 'link' && !(btn.link || '').trim()) hasError = true
+          })
+        }
+      })
+      if (hasError) {
+        ElMessage.error('保存失败:按钮模块中存在未填写的跳转链接!')
+        return
+      }
+      if (!data.name.trim()) data.name = '未命名组装页'
+      data.status = type === 'publish' ? 'published' : 'draft'
+
+      const res = await request.post('/api/assembly/save', data)
+      if (!data.id) data.id = res.data.id
+      this.isModified = false
+      ElMessage.success(type === 'publish' ? '已发布组装页配置!' : '已保存组装页配置草稿!')
+      if (type === 'publish') this.backToList()
+    },
+
+    onQuillChange() {
+      if (this.quillReady) this.isModified = true
+    },
+
+    // 模块操作
+    addBlock(type) {
+      const block = { type, id: genBlockId(), data: {} }
+      if (type === 'image') block.data = { imageUrl: '' }
+      if (type === 'privilege') block.data = { title: '新权益标题', items: [{ id: '1', title: '模拟权益', desc: '描述' }] }
+      if (type === 'richtext') block.data = { content: '<p>输入富文本内容...</p>' }
+      if (type === 'buttonGroup') block.data = { buttons: [this.createDefaultButton({ text: '点击跳转', style: 'primary' })] }
+      this.editData.blocks.push(block)
+      this.isModified = true
+    },
+    duplicateBlock(index) {
+      const block = this.editData.blocks[index]
+      if (block.type === 'privilege') {
+        ElMessage.warning('权益商品模块每页仅限配置一个,无法复制!')
+        return
+      }
+      const copy = JSON.parse(JSON.stringify(block))
+      copy.id = genBlockId()
+      this.editData.blocks.splice(index + 1, 0, copy)
+      this.isModified = true
+    },
+    removeBlock(index) {
+      ElMessageBox.confirm('确定删除该模块吗?', '提示', { type: 'warning' }).then(() => {
+        this.editData.blocks.splice(index, 1)
+        this.isModified = true
+      }).catch(() => {})
+    },
+    moveBlock(from, to) {
+      if (to < 0 || to >= this.editData.blocks.length || from === to) return
+      const item = this.editData.blocks.splice(from, 1)[0]
+      this.editData.blocks.splice(to, 0, item)
+      this.isModified = true
+    },
+    movePrivilegeItem(bIdx, from, to) {
+      const items = this.editData.blocks[bIdx].data.items
+      if (to < 0 || to >= items.length || from === to) return
+      const item = items.splice(from, 1)[0]
+      items.splice(to, 0, item)
+      this.isModified = true
+    },
+
+    // 按钮操作
+    addButton(bIdx) {
+      const block = this.editData.blocks[bIdx]
+      if (!block.data.buttons) block.data.buttons = []
+      block.data.buttons.push(this.createDefaultButton())
+      this.isModified = true
+    },
+    handleButtonActionChange(btn) {
+      this.normalizeButton(btn)
+      this.isModified = true
+    },
+    removeButton(bIdx, btnIdx) {
+      this.editData.blocks[bIdx].data.buttons.splice(btnIdx, 1)
+      this.isModified = true
+    },
+    moveButton(bIdx, from, to) {
+      const buttons = this.editData.blocks[bIdx].data.buttons
+      if (to < 0 || to >= buttons.length || from === to) return
+      const item = buttons.splice(from, 1)[0]
+      buttons.splice(to, 0, item)
+      this.isModified = true
+    },
+
+    // 图片上传
+    triggerBlockUpload(bIdx) {
+      const ref = this.$refs['blockUpload' + bIdx]
+      if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+    },
+    onBlockImageUpload(event, bIdx) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        this.editData.blocks[bIdx].data.imageUrl = e.target.result
+        this.isModified = true
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+
+    // 链接弹窗
+    async showLinkDialog(id) {
+      if (!id) {
+        ElMessage.warning('请先保存组装页再获取链接!')
+        return
+      }
+      await this.loadLinkOptions()
+      this.linkTargetId = id
+      const item = this.tableData.find(a => a.id === id) || this.editData
+      this.linkTargetPublished = item && item.status === 'published'
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        ElMessage.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section .search-row { margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.button-group { display: inline-block; margin-left: 20px; }
+
+.toolbar {
+  display: flex; justify-content: space-between; align-items: center;
+  margin-bottom: 16px; padding: 12px 16px;
+  background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.section-card { margin-bottom: 16px; }
+.basic-info { display: flex; align-items: center; gap: 24px; }
+.info-item { display: flex; align-items: center; gap: 8px; }
+.info-label { font-size: 13px; color: #909399; white-space: nowrap; }
+
+.block-card {
+  background: #fff; border-radius: 8px; border: 1px solid #ebeef5;
+  margin-bottom: 16px; overflow: hidden; position: relative;
+  box-shadow: 0 1px 4px rgba(0,0,0,.04);
+}
+.block-header {
+  background: #f5f7fa; padding: 10px 160px 10px 16px; min-height: 44px;
+  border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px;
+  display: flex; align-items: center;
+}
+.block-header i { margin-right: 6px; color: #909399; }
+.button-module-header { gap: 14px; }
+.button-module-title { display: inline-flex; align-items: center; }
+.add-button-btn {
+  padding: 7px 10px;
+  background: #ecf5ff;
+  border-color: #d9ecff;
+}
+.block-hint { font-size: 12px; color: #c0c4cc; font-weight: normal; margin-left: 8px; }
+.block-body { padding: 16px; }
+.block-actions {
+  position: absolute; right: 0; top: 0; z-index: 10;
+  display: flex; gap: 4px; background: #f5f7fa;
+  border-bottom: 1px solid #ebeef5; border-left: 1px solid #ebeef5;
+  padding: 6px 8px; border-radius: 0 0 0 8px;
+}
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 256px; height: 144px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+}
+.upload-box-wide { width: 360px; height: 160px; }
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center; font-size: 14px;
+}
+.upload-overlay i { font-size: 24px; margin-bottom: 4px; }
+.upload-tip {
+  font-size: 12px; color: #909399; line-height: 1.6;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.privilege-row {
+  display: flex; align-items: center; gap: 12px;
+  padding: 10px 12px; border: 1px solid #ebeef5; border-radius: 6px;
+  background: #fafafa; margin-bottom: 8px;
+}
+.privilege-no { flex-shrink: 0; }
+.privilege-icon {
+  width: 40px; height: 40px; border-radius: 50%;
+  border: 1px solid #dcdfe6; background: #fff;
+  display: flex; align-items: center; justify-content: center;
+  color: #909399; font-size: 18px; flex-shrink: 0;
+}
+.privilege-info { flex: 1; opacity: .8; }
+.privilege-title { font-size: 13px; font-weight: 600; }
+.privilege-desc { font-size: 12px; color: #909399; margin-top: 2px; }
+
+.button-config-item {
+  position: relative; border: 1px solid #ebeef5; border-radius: 6px;
+  padding: 24px 122px 20px 20px; margin-bottom: 12px; background: #fafafa;
+}
+.btn-index {
+  position: absolute; left: 0; top: 0;
+  background: #606266; color: #fff; font-size: 10px; font-weight: 700;
+  padding: 2px 8px; border-radius: 6px 0 6px 0;
+}
+.btn-config-body { flex: 1; }
+.button-form .el-form-item { margin-bottom: 16px; }
+.button-form .el-form-item__label {
+  line-height: 20px;
+  padding: 0 0 6px;
+  color: #303133;
+  font-weight: 400;
+}
+.button-form .el-select { width: 100%; }
+.btn-config-actions {
+  position: absolute; right: 20px; top: 32px;
+  display: flex; gap: 6px;
+}
+.dialog-config {
+  background: #fff; padding: 18px 20px 4px; border: 1px solid #ebeef5; border-radius: 4px;
+  margin-top: 4px;
+}
+.dialog-config-title {
+  font-size: 14px; font-weight: 500; color: #303133;
+  border-bottom: 1px solid #ebeef5; padding-bottom: 12px; margin-bottom: 14px;
+}
+
+.add-block-bar {
+  display: flex; gap: 16px; justify-content: center;
+  padding: 20px; border: 2px dashed #dcdfe6; border-radius: 8px;
+  background: #fff; margin-bottom: 16px;
+}
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 16px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+</style>

+ 415 - 0
pc-v3/src/views/PageHomeConfig.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="page-home-config">
+    <!-- 顶部操作栏 -->
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <h2 class="toolbar-title">权益介绍页配置</h2>
+        <el-tag v-if="pageStatus === 'published'" type="success" size="small" effect="plain">正式线上环境</el-tag>
+        <el-tag v-else-if="pageStatus === 'modified'" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
+        <el-tag v-else size="small" effect="plain">草稿 (未发布)</el-tag>
+      </div>
+      <div class="toolbar-right">
+        <el-button size="small" @click="showLinkDialog">
+          <el-icon><Link /></el-icon>获取链接
+        </el-button>
+        <el-button size="small" type="primary" plain @click="handleSave('draft')">保存草稿</el-button>
+        <el-button size="small" type="primary" @click="handleSave('publish')">发布上线</el-button>
+      </div>
+    </div>
+
+    <!-- 客群Tab -->
+    <el-tabs v-model="currentTier" @tab-click="onTierChange">
+      <el-tab-pane
+        v-for="tier in tiers"
+        :key="tier.key"
+        :label="configData[tier.key] ? configData[tier.key].tabName : tier.label"
+        :name="tier.key"
+      />
+    </el-tabs>
+
+    <!-- 配置表单 -->
+    <div v-if="currentTierData" class="config-sections">
+      <!-- Tab名称配置 -->
+      <el-card class="section-card" shadow="hover">
+        <template #header>
+          <div class="section-header">
+            <el-icon><FolderOpened /></el-icon> Tab名称配置
+          </div>
+        </template>
+        <el-form label-width="80px">
+          <el-form-item label="Tab名称">
+            <el-input v-model="currentTierData.tabName" placeholder="例如:卓越理财" style="width: 50%" @input="markModified" />
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 头部配置 -->
+      <el-card class="section-card" shadow="hover">
+        <template #header>
+          <div class="section-header">
+            <el-icon><PictureRounded /></el-icon> 头部配置
+          </div>
+        </template>
+        <el-form class="header-config-form" label-width="130px">
+          <el-form-item label="头图配图">
+            <div class="image-upload-area">
+              <div class="upload-box" @click="triggerUpload('header')">
+                <img v-if="currentTierData.header.imageUrl" :src="currentTierData.header.imageUrl" class="preview-img" />
+                <div v-else class="upload-placeholder">
+                  <el-icon><Picture /></el-icon>
+                  <span>未上传图片</span>
+                </div>
+                <div class="upload-overlay">
+                  <el-icon><Upload /></el-icon>
+                  <span>点击上传</span>
+                </div>
+              </div>
+              <div class="upload-tip">建议图片尺寸:<b>686 × 296 px</b></div>
+            </div>
+            <input ref="headerUpload" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'header')" />
+          </el-form-item>
+          <el-form-item label="主标题(粗体)">
+            <el-input v-model="currentTierData.header.mainTitle" @input="markModified" />
+          </el-form-item>
+          <el-form-item label="副标题(小字)">
+            <el-input v-model="currentTierData.header.subTitle" @input="markModified" />
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 按钮配置 -->
+      <el-card class="section-card" shadow="hover">
+        <template #header>
+          <div class="section-header">
+            <el-icon><Pointer /></el-icon> 按钮配置
+          </div>
+        </template>
+        <el-form label-width="130px">
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="已解锁按钮文案">
+                <el-input v-model="currentTierData.buttons.unlocked" placeholder="例如:进入权益专区" @input="markModified" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="待解锁按钮文案">
+                <el-input v-model="currentTierData.buttons.locked" placeholder="例如:待解锁" @input="markModified" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </el-card>
+
+      <!-- 权益列表配置 -->
+      <el-card class="section-card" shadow="hover">
+        <template #header>
+          <div class="section-header">
+            <el-icon><Grid /></el-icon> 权益列表配置
+            <el-button type="text" @click="addPrivilege" style="float:right">
+              <el-icon><CirclePlus /></el-icon>添加权益
+            </el-button>
+          </div>
+        </template>
+        <div v-if="currentTierData.privileges.length === 0" class="empty-tip">
+          暂无权益项
+        </div>
+        <div v-for="(item, index) in currentTierData.privileges" :key="index" class="privilege-item">
+          <div class="privilege-index">NO. {{ index + 1 }}</div>
+          <div class="privilege-actions">
+            <el-popover placement="bottom" trigger="click" width="180">
+              <div>
+                <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                <div style="display:flex;gap:8px">
+                  <el-input-number v-model="sortTarget" :min="1" :max="currentTierData.privileges.length" size="small" controls-position="right" style="width:100px" />
+                  <el-button size="small" type="primary" @click="movePrivilege(index, sortTarget - 1)">确定</el-button>
+                </div>
+              </div>
+              <template #reference>
+                <el-button size="small" circle @click="sortTarget = index + 1">
+                  <el-icon><Sort /></el-icon>
+                </el-button>
+              </template>
+            </el-popover>
+            <el-button size="small" type="danger" circle @click="removePrivilege(index)">
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </div>
+          <div class="privilege-content">
+            <div class="privilege-image" @click="triggerUpload('privilege', index)">
+              <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img-sm" />
+              <div v-else class="upload-placeholder-sm">
+                <el-icon><Picture /></el-icon>
+                <span>未上传</span>
+              </div>
+              <input :ref="'privilegeUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'privilege', index)" />
+            </div>
+            <div class="privilege-text-tip">非必填<br/>无图展示圆点</div>
+            <div class="privilege-fields">
+              <el-form label-width="60px" inline>
+                <el-form-item label="标题">
+                  <el-input v-model="item.title" style="width:160px" @input="markModified" />
+                </el-form-item>
+                <el-form-item label="描述">
+                  <el-input v-model="item.desc" style="width:300px" @input="markModified" />
+                </el-form-item>
+              </el-form>
+            </div>
+          </div>
+        </div>
+      </el-card>
+    </div>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" v-model="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable filterable placeholder="请选择 (无)" style="width:240px" @change="updateLinks">
+            <el-option v-for="opt in campaignOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable filterable placeholder="请选择 (无)" style="width:240px" @change="updateLinks">
+            <el-option v-for="opt in channelOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="pageStatus === 'published'" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5链接</span>
+          <el-input :value="prodH5Url" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodH5Url)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序链接</span>
+          <el-input :value="prodAppUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodAppUrl)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="testUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(testUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import request from '@/utils/request'
+import { fetchLinkOptions } from '@/utils/linkOptions'
+
+export default {
+  name: 'PageHomeConfig',
+  data() {
+    return {
+      currentTier: 'premier',
+      tiers: [
+        { key: 'premier', label: '卓越理财' },
+        { key: 'elite', label: '卓越理财·尊尚' },
+        { key: 'global', label: '环球私人银行' }
+      ],
+      configData: {},
+      pageStatus: 'draft',
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' },
+      campaignOptions: [],
+      channelOptions: []
+    }
+  },
+  computed: {
+    currentTierData() {
+      return this.configData[this.currentTier] || null
+    },
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '?' + params.join('&') : ''
+    },
+    prodH5Url() {
+      return `https://hsbc.com/home${this.linkQueryStr}`
+    },
+    prodAppUrl() {
+      return `hsbc-app://home${this.linkQueryStr}`
+    },
+    testUrl() {
+      const q = this.linkQueryStr
+      return `https://test.hsbc.com/home${q ? q + '&' : '?'}draft=1`
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      const res = await request.get('/api/page-config/home')
+      this.configData = res.data.config
+      this.pageStatus = res.data.status
+    },
+    async loadLinkOptions() {
+      const { campaignOptions, channelOptions } = await fetchLinkOptions()
+      this.campaignOptions = campaignOptions
+      this.channelOptions = channelOptions
+    },
+    markModified() {
+      if (this.pageStatus === 'published') {
+        this.pageStatus = 'modified'
+      }
+    },
+    onTierChange() {},
+    triggerUpload(type, index) {
+      if (type === 'header') {
+        this.$refs.headerUpload.click()
+      } else {
+        const ref = this.$refs['privilegeUpload' + index]
+        if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+      }
+    },
+    onImageUpload(event, type, index) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        if (type === 'header') {
+          this.currentTierData.header.imageUrl = e.target.result
+        } else {
+          this.currentTierData.privileges[index].imageUrl = e.target.result
+        }
+        this.markModified()
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+    addPrivilege() {
+      this.currentTierData.privileges.push({ imageUrl: '', title: '新权益', desc: '描述...' })
+      this.markModified()
+    },
+    removePrivilege(index) {
+      this.currentTierData.privileges.splice(index, 1)
+      this.markModified()
+    },
+    movePrivilege(from, to) {
+      if (to < 0 || to >= this.currentTierData.privileges.length || from === to) return
+      const item = this.currentTierData.privileges.splice(from, 1)[0]
+      this.currentTierData.privileges.splice(to, 0, item)
+      this.markModified()
+    },
+    async handleSave(type) {
+      const status = type === 'publish' ? 'published' : 'draft'
+      await request.put('/api/page-config/home', { config: this.configData, status })
+      this.pageStatus = status
+      ElMessage.success(type === 'publish' ? '已发布权益介绍页配置!' : '已保存权益介绍页配置草稿!')
+    },
+    async showLinkDialog() {
+      await this.loadLinkOptions()
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    updateLinks() {},
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        ElMessage.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+  padding: 12px 16px;
+  background: #fff;
+  border-radius: 4px;
+  box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.config-sections { margin-top: 16px; }
+.section-card { margin-bottom: 16px; }
+.section-header { font-weight: 600; }
+.section-header i { margin-right: 6px; color: #909399; }
+.header-config-form .el-form-item__label { white-space: nowrap; }
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 360px; height: 160px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+}
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center;
+  font-size: 14px;
+}
+.upload-overlay i { font-size: 28px; margin-bottom: 6px; }
+.upload-tip {
+  font-size: 12px; color: #909399;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.privilege-item {
+  position: relative;
+  border: 1px solid #ebeef5; border-radius: 8px;
+  padding: 20px 16px 16px; margin-bottom: 12px;
+  background: #fff;
+}
+.privilege-index {
+  position: absolute; left: 0; top: 0;
+  background: #409EFF; color: #fff;
+  font-size: 11px; font-weight: 700;
+  padding: 2px 10px; border-radius: 8px 0 8px 0;
+}
+.privilege-actions {
+  position: absolute; right: 12px; top: 12px;
+  display: flex; gap: 6px;
+}
+.privilege-content { display: flex; align-items: flex-start; gap: 12px; margin-top: 8px; }
+.privilege-image {
+  width: 56px; height: 56px; flex-shrink: 0;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer; overflow: hidden; position: relative;
+}
+.preview-img-sm { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder-sm {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 10px;
+}
+.upload-placeholder-sm i { font-size: 18px; }
+.privilege-text-tip { font-size: 10px; color: #c0c4cc; text-align: center; line-height: 1.3; flex-shrink: 0; margin-top: 4px; }
+.privilege-fields { flex: 1; }
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 24px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+</style>

+ 24 - 0
pc-v3/src/views/PageManagement.vue

@@ -0,0 +1,24 @@
+<template>
+  <div class="page-management">
+    <el-card>
+      <template #header>
+        <div>
+          <span>页面管理</span>
+        </div>
+      </template>
+      <el-empty description="页面管理(开发中)"></el-empty>
+    </el-card>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'PageManagement'
+}
+</script>
+
+<style scoped>
+.page-management {
+  padding: 0;
+}
+</style>

+ 626 - 0
pc-v3/src/views/PageZoneConfig.vue

@@ -0,0 +1,626 @@
+<template>
+  <div class="page-zone-config">
+    <!-- 顶部操作栏 -->
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <h2 class="toolbar-title">权益首页配置</h2>
+        <el-tag v-if="pageStatus === 'published'" type="success" size="small" effect="plain">正式线上环境</el-tag>
+        <el-tag v-else-if="pageStatus === 'modified'" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
+        <el-tag v-else size="info" effect="plain">草稿 (未发布)</el-tag>
+      </div>
+      <div class="toolbar-right">
+        <el-button size="small" @click="showLinkDialog">
+          <el-icon><Link /></el-icon>获取链接
+        </el-button>
+        <el-button size="small" type="primary" plain @click="handleSave('draft')">保存草稿</el-button>
+        <el-button size="small" type="primary" @click="handleSave('publish')">发布上线</el-button>
+      </div>
+    </div>
+
+    <!-- 客户身份模块配置 -->
+    <el-card class="section-card" shadow="hover">
+      <template #header>
+        <div class="section-header">
+          <span><el-icon><Postcard /></el-icon> 客户身份模块配置</span>
+        </div>
+      </template>
+      <el-alert
+        type="info"
+        :closable="false"
+        show-icon
+        title="前端将根据登录客户的实际客群 (MSC) 自动匹配展示以下对应的身份卡片内容。若关闭“显示账户月内日均余额”开关,则对应的计算规则弹窗配置项将自动隐藏,前端也不会展示。"
+        style="margin-bottom:16px"
+      />
+
+      <div v-for="m in identityMscList" :key="m.key" class="identity-card">
+        <div class="identity-title"><span class="title-bar"></span>{{ m.label }}</div>
+
+        <!-- 客群背景图 -->
+        <div class="identity-row">
+          <div class="identity-field-label">客群背景图</div>
+          <div class="bg-upload-area">
+            <div class="bg-upload-box" @click="triggerIdentityUpload(m.key)">
+              <img v-if="configData.identityModule[m.key].bgImage" :src="configData.identityModule[m.key].bgImage" class="preview-img" />
+              <div v-else class="upload-placeholder">
+                <el-icon><Picture /></el-icon>
+                <span>未上传</span>
+              </div>
+              <div class="upload-overlay">
+                <el-icon><Upload /></el-icon>
+                <span>点击上传</span>
+              </div>
+            </div>
+            <div class="upload-tip">建议图片尺寸:<b>750 × 185 px</b></div>
+            <input :ref="'identityUpload_' + m.key" type="file" accept="image/*" style="display:none" @change="onIdentityImageUpload($event, m.key)" />
+          </div>
+        </div>
+
+        <!-- 身份标识文字 + 右侧开关 -->
+        <div class="identity-row identity-text-row">
+          <div class="identity-field">
+            <div class="identity-field-label">身份标识文字 <span class="muted">(显示于移动端卡片左上角)</span></div>
+            <el-input
+              v-model="configData.identityModule[m.key].identityText"
+              maxlength="20"
+              placeholder="请输入"
+              style="width:360px"
+              @input="markModified"
+            />
+          </div>
+          <div class="identity-toggles">
+            <template v-if="m.key !== 'privateBank'">
+              <el-checkbox v-model="configData.identityModule[m.key].showQualifyStatus" @change="markModified">显示合格状态 (及编号)</el-checkbox>
+              <el-checkbox v-model="configData.identityModule[m.key].showAvgBalance" @change="markModified">显示“账户月内日均余额”</el-checkbox>
+            </template>
+            <template v-else>
+              <el-checkbox v-model="configData.identityModule.privateBank.showPbLevel" @change="markModified">显示PB权益等级及编号 (支持复制)</el-checkbox>
+              <div class="pb-hint">(PB客群不显示月内日均余额)</div>
+            </template>
+          </div>
+        </div>
+
+        <!-- 日均余额计算规则说明 -->
+        <div
+          v-if="m.key !== 'privateBank' && configData.identityModule[m.key].showAvgBalance"
+          class="rule-block"
+        >
+          <div class="rule-label">
+            <el-icon><QuestionFilled /></el-icon>
+            日均余额计算规则说明 <span class="muted">(前端点击问号展示)</span>
+          </div>
+          <el-input
+            v-model="configData.identityModule[m.key].avgBalanceRule"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入计算规则说明"
+            @input="markModified"
+          />
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 活动广告轮播图 -->
+    <el-card class="section-card" shadow="hover">
+      <template #header>
+        <div class="section-header">
+          <span><el-icon><DataBoard /></el-icon> 活动广告轮播图</span>
+          <el-button type="text" @click="addCarousel" style="float:right">
+            <el-icon><CirclePlus /></el-icon>添加轮播
+          </el-button>
+        </div>
+      </template>
+      <div v-if="configData.adCarousel.length === 0" class="empty-tip">暂无广告轮播图</div>
+      <div v-for="(item, index) in configData.adCarousel" :key="'c' + index" class="carousel-item">
+        <div class="item-index">NO. {{ index + 1 }}</div>
+        <div class="item-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="configData.adCarousel.length" size="small" controls-position="right" style="width:100px" />
+                <el-button size="small" type="primary" @click="moveItem('adCarousel', index, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <template #reference>
+              <el-button size="small" circle @click="sortTarget = index + 1">
+                <el-icon><Sort /></el-icon>
+              </el-button>
+            </template>
+          </el-popover>
+          <el-button size="small" type="danger" circle @click="removeCarousel(index)">
+            <el-icon><Delete /></el-icon>
+          </el-button>
+        </div>
+
+        <!-- 图片上传 -->
+        <div class="carousel-body">
+          <div class="image-upload-area">
+            <div class="upload-box" @click="triggerUpload('carousel', index)">
+              <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img" />
+              <div v-else class="upload-placeholder">
+                <el-icon><Picture /></el-icon>
+                <span>未上传</span>
+              </div>
+              <div class="upload-overlay">
+                <el-icon><Upload /></el-icon>
+                <span>点击上传</span>
+              </div>
+            </div>
+            <div class="upload-tip">建议图片尺寸:<b>686 × 296 px</b></div>
+            <input :ref="'carouselUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'carousel', index)" />
+          </div>
+
+          <!-- 标题 -->
+          <el-form label-width="80px" style="margin-top:12px">
+            <el-form-item label="主标题">
+              <el-input v-model="item.title" @input="markModified" />
+            </el-form-item>
+            <el-form-item label="副标题">
+              <el-input v-model="item.subtitle" @input="markModified" />
+            </el-form-item>
+          </el-form>
+
+          <!-- 可见人群标签 -->
+          <el-form label-width="110px" style="margin-top:4px">
+            <el-form-item label="可见人群标签">
+              <el-select v-model="item.tags" multiple placeholder="点击选择..." filterable @change="markModified" style="width:100%">
+                <el-option v-for="tag in availableTags" :key="tag.id" :label="tag.name" :value="tag.id" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+
+          <!-- 跳转链接配置 -->
+          <div class="link-config-block">
+            <div class="link-config-title"><el-icon><Link /></el-icon> 跳转链接配置</div>
+            <el-form label-width="140px">
+              <el-form-item label="H5 跳转链接">
+                <el-input v-model="item.linkH5" @input="markModified" />
+              </el-form-item>
+              <el-form-item label="APP 跳转链接">
+                <el-input v-model="item.linkApp" @input="markModified" />
+              </el-form-item>
+              <el-row :gutter="20">
+                <el-col :span="12">
+                  <el-form-item label="微信小程序 AppID">
+                    <el-input v-model="item.wechatAppId" placeholder="例如:wx1234567890abcdef" @input="markModified" />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="12">
+                  <el-form-item label="微信小程序页面路径">
+                    <el-input v-model="item.wechatPath" placeholder="例如:pages/index/index" @input="markModified" />
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </el-form>
+          </div>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 常用兑换配置 -->
+    <el-card class="section-card" shadow="hover">
+      <template #header>
+        <div class="section-header">
+          <span><el-icon><Grid /></el-icon> 常用兑换配置</span>
+          <el-button type="text" @click="addExchangeItem" style="float:right">
+            <el-icon><CirclePlus /></el-icon>添加兑换项
+          </el-button>
+        </div>
+      </template>
+      <div v-if="configData.exchangeConfig.items.length === 0" class="empty-tip">暂无常用兑换项</div>
+      <div v-for="(item, index) in configData.exchangeConfig.items" :key="'e' + index" class="exchange-item">
+        <div class="item-index">NO. {{ index + 1 }}</div>
+        <div class="item-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="configData.exchangeConfig.items.length" size="small" controls-position="right" style="width:100px" />
+                <el-button size="small" type="primary" @click="moveItem('exchangeItems', index, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <template #reference>
+              <el-button size="small" circle @click="sortTarget = index + 1">
+                <el-icon><Sort /></el-icon>
+              </el-button>
+            </template>
+          </el-popover>
+          <el-button size="small" type="danger" circle @click="removeExchangeItem(index)">
+            <el-icon><Delete /></el-icon>
+          </el-button>
+        </div>
+        <div class="exchange-body">
+          <div class="exchange-image" @click="triggerUpload('exchange', index)">
+            <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img-sq" />
+            <div v-else class="upload-placeholder-sm">
+              <el-icon><Picture /></el-icon>
+              <span>未上传</span>
+            </div>
+            <input :ref="'exchangeUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'exchange', index)" />
+          </div>
+          <el-form label-width="80px" inline class="exchange-fields">
+            <el-form-item label="展示文字">
+              <el-input v-model="item.title" style="width:200px" @input="markModified" />
+            </el-form-item>
+            <el-form-item label="跳转链接">
+              <el-input v-model="item.link" style="width:280px" @input="markModified" />
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 积分规则弹窗配置 -->
+    <el-card class="section-card" shadow="hover">
+      <template #header>
+        <div class="section-header">
+          <el-icon><Document /></el-icon> 积分规则弹窗配置
+        </div>
+      </template>
+      <el-form label-width="120px">
+        <el-form-item label="弹窗顶部名称">
+          <el-input v-model="configData.ruleModal.title" style="width:50%" @input="markModified" />
+        </el-form-item>
+        <el-form-item label="规则内容">
+          <quill-editor
+            v-model:content="configData.ruleModal.content"
+            content-type="html"
+            :options="quillOptions"
+            @change="markModified"
+          />
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" v-model="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+            <el-option v-for="opt in campaignOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable filterable placeholder="请选择 (无)" style="width:240px">
+            <el-option v-for="opt in channelOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="pageStatus === 'published'" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5链接</span>
+          <el-input :value="prodH5Url" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodH5Url)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序链接</span>
+          <el-input :value="prodAppUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodAppUrl)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="testUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(testUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import request from '@/utils/request'
+import { fetchLinkOptions } from '@/utils/linkOptions'
+
+export default {
+  name: 'PageZoneConfig',
+  data() {
+    return {
+      configData: {
+        identityModule: this.defaultIdentityModule(),
+        adCarousel: [],
+        exchangeConfig: { items: [] },
+        ruleModal: { title: '', content: '' }
+      },
+      identityMscList: [
+        { key: 'advance', label: '运筹理财 (Advance)' },
+        { key: 'premier', label: '卓越理财 (Premier)' },
+        { key: 'premierElite', label: '卓越理财·尊尚 (Premier Elite)' },
+        { key: 'privateBank', label: '环球私人银行 (Private Bank)' }
+      ],
+      availableTags: [],
+      pageStatus: 'draft',
+      dataLoaded: false,
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' },
+      campaignOptions: [],
+      channelOptions: [],
+      quillOptions: {
+        placeholder: '请输入积分规则内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      }
+    }
+  },
+  computed: {
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '?' + params.join('&') : ''
+    },
+    prodH5Url() {
+      return `https://hsbc.com/zone${this.linkQueryStr}`
+    },
+    prodAppUrl() {
+      return `hsbc-app://zone${this.linkQueryStr}`
+    },
+    testUrl() {
+      const q = this.linkQueryStr
+      return `https://test.hsbc.com/zone${q ? q + '&' : '?'}draft=1`
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    defaultIdentityModule() {
+      const base = { bgImage: '', identityText: '', showQualifyStatus: false, showAvgBalance: true, avgBalanceRule: '' }
+      return {
+        advance: { ...base },
+        premier: { ...base },
+        premierElite: { ...base },
+        privateBank: { bgImage: '', identityText: '', showPbLevel: true }
+      }
+    },
+    async fetchData() {
+      const res = await request.get('/api/page-config/zone')
+      const cfg = res.data.config || {}
+      if (!cfg.identityModule) cfg.identityModule = this.defaultIdentityModule()
+      this.configData = cfg
+      this.availableTags = res.data.availableTags
+      this.pageStatus = res.data.status
+      this.$nextTick(() => { this.dataLoaded = true })
+    },
+    triggerIdentityUpload(key) {
+      const ref = this.$refs['identityUpload_' + key]
+      if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+    },
+    onIdentityImageUpload(event, key) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        this.configData.identityModule[key].bgImage = e.target.result
+        this.markModified()
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+    async loadLinkOptions() {
+      const { campaignOptions, channelOptions } = await fetchLinkOptions()
+      this.campaignOptions = campaignOptions
+      this.channelOptions = channelOptions
+    },
+    markModified() {
+      if (!this.dataLoaded) return
+      if (this.pageStatus === 'published') {
+        this.pageStatus = 'modified'
+      }
+    },
+    triggerUpload(type, index) {
+      const ref = this.$refs[type + 'Upload' + index]
+      if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+    },
+    onImageUpload(event, type, index) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        if (type === 'carousel') {
+          this.configData.adCarousel[index].imageUrl = e.target.result
+        } else {
+          this.configData.exchangeConfig.items[index].imageUrl = e.target.result
+        }
+        this.markModified()
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+    addCarousel() {
+      this.configData.adCarousel.push({
+        imageUrl: '', linkH5: '', linkApp: '', wechatAppId: '', wechatPath: '',
+        title: '新广告', subtitle: '', tags: []
+      })
+      this.markModified()
+    },
+    removeCarousel(index) {
+      this.configData.adCarousel.splice(index, 1)
+      this.markModified()
+    },
+    addExchangeItem() {
+      this.configData.exchangeConfig.items.push({ imageUrl: '', title: '新兑换项', link: '' })
+      this.markModified()
+    },
+    removeExchangeItem(index) {
+      this.configData.exchangeConfig.items.splice(index, 1)
+      this.markModified()
+    },
+    moveItem(type, from, to) {
+      const arr = type === 'adCarousel' ? this.configData.adCarousel : this.configData.exchangeConfig.items
+      if (to < 0 || to >= arr.length || from === to) return
+      const item = arr.splice(from, 1)[0]
+      arr.splice(to, 0, item)
+      this.markModified()
+    },
+    async handleSave(type) {
+      const status = type === 'publish' ? 'published' : 'draft'
+      await request.put('/api/page-config/zone', { config: this.configData, status })
+      this.pageStatus = status
+      ElMessage.success(type === 'publish' ? '已发布权益首页配置!' : '已保存权益首页配置草稿!')
+    },
+    async showLinkDialog() {
+      await this.loadLinkOptions()
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        ElMessage.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.toolbar {
+  display: flex; justify-content: space-between; align-items: center;
+  margin-bottom: 16px; padding: 12px 16px;
+  background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.section-card { margin-bottom: 16px; }
+.section-header { font-weight: 600; }
+.section-header i { margin-right: 6px; color: #909399; }
+
+.carousel-item, .exchange-item {
+  position: relative; border: 1px solid #ebeef5; border-radius: 8px;
+  padding: 24px 16px 16px; margin-bottom: 16px; background: #fff;
+}
+.item-index {
+  position: absolute; left: 0; top: 0;
+  background: #409EFF; color: #fff; font-size: 11px; font-weight: 700;
+  padding: 2px 10px; border-radius: 8px 0 8px 0;
+}
+.item-actions {
+  position: absolute; right: 12px; top: 12px;
+  display: flex; gap: 6px;
+}
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 256px; height: 144px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+}
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center; font-size: 14px;
+}
+.upload-overlay i { font-size: 24px; margin-bottom: 4px; }
+.upload-tip {
+  font-size: 12px; color: #909399;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.link-config-block {
+  background: #f5f7fa; padding: 16px; border-radius: 6px;
+  border: 1px solid #ebeef5; margin-top: 8px;
+}
+.link-config-title {
+  font-size: 13px; font-weight: 600; color: #606266;
+  border-bottom: 1px solid #ebeef5; padding-bottom: 8px; margin-bottom: 12px;
+}
+
+.exchange-body { display: flex; align-items: flex-start; gap: 16px; margin-top: 8px; }
+.exchange-image {
+  width: 80px; height: 80px; flex-shrink: 0;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer; overflow: hidden;
+}
+.preview-img-sq { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder-sm {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 10px;
+}
+.upload-placeholder-sm i { font-size: 18px; }
+.exchange-fields { flex: 1; margin-top: 8px; }
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 24px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+
+/* 客户身份模块配置 */
+.identity-card {
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  padding: 16px 20px;
+  margin-bottom: 16px;
+  background: #fff;
+}
+.identity-title {
+  display: flex; align-items: center;
+  font-size: 14px; font-weight: 600; color: #303133;
+  margin-bottom: 14px;
+}
+.title-bar {
+  display: inline-block; width: 4px; height: 14px;
+  background: #409EFF; border-radius: 2px; margin-right: 8px;
+}
+.identity-row { margin-bottom: 14px; }
+.identity-field-label {
+  font-size: 13px; color: #606266; margin-bottom: 6px;
+}
+.muted { color: #909399; font-weight: normal; font-size: 12px; }
+.bg-upload-area {
+  display: flex; align-items: center; gap: 16px;
+}
+.bg-upload-box {
+  width: 300px; height: 75px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+}
+.bg-upload-box:hover .upload-overlay { display: flex; }
+.identity-text-row {
+  display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
+}
+.identity-field { flex: 1; }
+.identity-toggles {
+  display: flex; flex-direction: column; gap: 6px; padding-top: 22px;
+}
+.pb-hint { font-size: 12px; color: #909399; margin-top: 4px; }
+.rule-block {
+  background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
+  padding: 12px 14px;
+}
+.rule-label {
+  font-size: 13px; color: #606266; margin-bottom: 8px;
+}
+.rule-label i { color: #409EFF; margin-right: 4px; }
+</style>

+ 177 - 0
pc-v3/src/views/PointsDetail.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="points-detail">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <el-form :model="searchForm" label-width="100px" inline>
+          <el-form-item label="客户号:">
+            <el-input v-model="searchForm.customerId" placeholder="请输入客户号" clearable style="width: 220px;"></el-input>
+          </el-form-item>
+          <el-form-item label="变动类型:">
+            <el-select v-model="searchForm.changeType" placeholder="请选择变动类型" clearable style="width: 220px;">
+              <el-option v-for="item in changeTypeOptions" :key="item" :label="item" :value="item"></el-option>
+            </el-select>
+          </el-form-item>
+          <el-form-item label="查询日期范围:">
+            <el-date-picker
+              v-model="searchForm.dateRange"
+              type="daterange"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              value-format="yyyy-MM-dd"
+              style="width: 360px;"
+            ></el-date-picker>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch">查询</el-button>
+            <el-button @click="handleReset">重置</el-button>
+            <el-button type="success" @click="handleExport">
+              <el-icon><Download /></el-icon>导出
+            </el-button>
+          </el-form-item>
+        </el-form>
+        </div>
+      </template>
+
+      <el-table :data="pagedData" stripe border style="width: 100%;" v-loading="loading">
+        <el-table-column prop="customerId" label="客户号"></el-table-column>
+        <el-table-column prop="changeDate" label="变动日期"></el-table-column>
+        <el-table-column prop="changeAmount" label="变动积分金额"></el-table-column>
+        <el-table-column prop="totalPoints" label="积分总数"></el-table-column>
+        <el-table-column prop="hyhPoints" label="HYH积分"></el-table-column>
+        <el-table-column prop="creditCardPoints" label="信用卡积分"></el-table-column>
+        <el-table-column prop="changeType" label="变动类别">
+          <template #default="scope">
+            <el-tag :type="changeTypeTag[scope.row.changeType]" size="small">{{ scope.row.changeType }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="projectName" label="积分项目类别" show-overflow-tooltip></el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 20px; text-align: right;"
+        @current-change="handlePageChange"
+        :current-page="currentPage"
+        :page-size="pageSize"
+        layout="total, prev, pager, next, jumper"
+        :total="filteredData.length"
+      ></el-pagination>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+
+const CHANGE_TYPES = ['获取积分', '使用积分', '积分过期']
+const EARN_PROJECTS = ['消费返积分', '开户礼积分', '生日奖励积分', '活动赠送积分', '推荐好友奖励', '月度达标奖励']
+const SPEND_PROJECTS = ['积分兑换商品', '积分抵现', '积分兑换券码', '积分兑换里程', '积分兑换权益']
+
+function pad(n) { return String(n).padStart(2, '0') }
+
+function generateMockData() {
+  const list = []
+  const customerIds = ['CUST100231', 'CUST100458', 'CUST100672', 'CUST100899', 'CUST101024', 'CUST101337', 'CUST101582', 'CUST101790']
+
+  for (let i = 1; i <= 80; i++) {
+    const d = new Date(2026, 4, 15)
+    d.setDate(d.getDate() - Math.floor(Math.random() * 120))
+    const dateStr = `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`
+
+    const changeType = CHANGE_TYPES[Math.floor(Math.random() * CHANGE_TYPES.length)]
+    let changeAmount, projectName
+    if (changeType === '获取积分') {
+      changeAmount = Math.floor(Math.random() * 500 + 1)
+      projectName = EARN_PROJECTS[Math.floor(Math.random() * EARN_PROJECTS.length)]
+    } else if (changeType === '使用积分') {
+      changeAmount = Math.floor(Math.random() * 300 + 1)
+      projectName = SPEND_PROJECTS[Math.floor(Math.random() * SPEND_PROJECTS.length)]
+    } else {
+      changeAmount = Math.floor(Math.random() * 200 + 50)
+      projectName = '/'
+    }
+
+    const totalPoints = Math.floor(Math.random() * 5000 + 100)
+    const hyhPoints = Math.floor(totalPoints * (Math.random() * 0.4 + 0.1))
+    const creditCardPoints = totalPoints - hyhPoints
+
+    list.push({
+      id: i,
+      customerId: customerIds[Math.floor(Math.random() * customerIds.length)],
+      changeDate: dateStr,
+      changeAmount: changeAmount,
+      totalPoints: totalPoints,
+      hyhPoints: hyhPoints,
+      creditCardPoints: creditCardPoints,
+      changeType: changeType,
+      projectName: projectName
+    })
+  }
+  list.sort((a, b) => b.changeDate.localeCompare(a.changeDate))
+  return list
+}
+
+export default {
+  name: 'PointsDetail',
+  data() {
+    return {
+      searchForm: { customerId: '', changeType: '', dateRange: [] },
+      loading: false,
+      currentPage: 1,
+      pageSize: 10,
+      tableData: generateMockData(),
+      changeTypeOptions: CHANGE_TYPES,
+      changeTypeTag: {
+        '获取积分': 'success',
+        '使用积分': '',
+        '积分过期': 'info'
+      }
+    }
+  },
+  computed: {
+    filteredData() {
+      let list = this.tableData
+      const keyword = this.searchForm.customerId.trim()
+      if (keyword) {
+        list = list.filter(item => item.customerId.includes(keyword))
+      }
+      if (this.searchForm.changeType) {
+        list = list.filter(item => item.changeType === this.searchForm.changeType)
+      }
+      if (this.searchForm.dateRange && this.searchForm.dateRange.length === 2) {
+        const start = this.searchForm.dateRange[0].replace(/-/g, '/')
+        const end = this.searchForm.dateRange[1].replace(/-/g, '/')
+        list = list.filter(item => item.changeDate >= start && item.changeDate <= end)
+      }
+      return list
+    },
+    pagedData() {
+      const start = (this.currentPage - 1) * this.pageSize
+      return this.filteredData.slice(start, start + this.pageSize)
+    }
+  },
+  methods: {
+    handleSearch() {
+      this.currentPage = 1
+    },
+    handleReset() {
+      this.searchForm = { customerId: '', changeType: '', dateRange: [] }
+      this.currentPage = 1
+    },
+    handlePageChange(page) {
+      this.currentPage = page
+    },
+    handleExport() {
+      ElMessage.success('导出任务已提交,请稍后在下载中心查看')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 302 - 0
pc-v3/src/views/RuleGroupEdit.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="rule-group-edit">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">{{ isEdit ? '编辑客群规则任务' : '新增客群规则任务' }}</span>
+          <span v-if="isEdit && current" class="rg-id">编号:{{ current.id }}</span>
+        </div>
+      </template>
+
+      <!-- 步骤指示 -->
+      <el-steps :active="step" finish-status="success" align-center class="steps">
+        <el-step title="基本信息">
+          <template #icon><el-icon><EditPen /></el-icon></template>
+        </el-step>
+        <el-step title="规则配置">
+          <template #icon><el-icon><SetUp /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- Step 1: 基本信息 -->
+      <div v-show="step === 0" class="section">
+        <div class="section-title">基本信息</div>
+        <el-form
+          ref="baseForm"
+          :model="form"
+          :rules="rules"
+          label-width="120px"
+          style="max-width: 760px; margin: 0 auto"
+        >
+          <el-form-item label="规则组名称" prop="name">
+            <el-input v-model="form.name" placeholder="最长 40 字符" maxlength="40" show-word-limit />
+          </el-form-item>
+          <el-form-item label="规则组类型" prop="type">
+            <el-select v-model="form.type" placeholder="请选择" style="width: 100%">
+              <el-option
+                v-for="opt in groupTypes"
+                :key="opt.value"
+                :label="opt.label"
+                :value="opt.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="规则逻辑" prop="logic">
+            <el-radio-group v-model="form.logic">
+              <el-radio label="AND">且(AND,全部满足)</el-radio>
+              <el-radio label="OR">或(OR,任一满足)</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="备注说明">
+            <el-input
+              v-model="form.description"
+              type="textarea"
+              :rows="2"
+              placeholder="规则组用途、命中场景等,最长 200 字符"
+              maxlength="200"
+              show-word-limit
+            />
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <!-- Step 2: 规则配置 -->
+      <div v-show="step === 1" class="section">
+        <div class="section-title">
+          规则组配置
+          <span class="section-tip">规则之间使用「{{ form.logic === 'AND' ? '且' : '或' }}」逻辑组合</span>
+        </div>
+
+        <el-table :data="form.rules" border style="width: 100%" :empty-text="'暂无规则,请点击下方加号新增'">
+          <el-table-column prop="metaCode" label="元规则编号" width="110" align="center" />
+          <el-table-column label="元规则名称" min-width="180">
+            <template #default="scope">{{ metaName(scope.row.metaCode) }}</template>
+          </el-table-column>
+          <el-table-column prop="ruleName" label="规则名称" min-width="160" show-overflow-tooltip />
+          <el-table-column label="规则描述" min-width="240" show-overflow-tooltip>
+            <template #default="scope">{{ buildDesc(scope.row) }}</template>
+          </el-table-column>
+          <el-table-column label="规则参数" min-width="220" show-overflow-tooltip>
+            <template #default="scope">
+              <span class="params-text">{{ buildParam(scope.row) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="140" align="center">
+            <template #default="scope">
+              <el-button
+                type="primary"
+                circle
+                size="small"
+                title="编辑"
+                @click="openRuleDialog('edit', scope.row, scope.$index)"
+              >
+                <el-icon><Edit /></el-icon>
+              </el-button>
+              <el-button
+                type="warning"
+                circle
+                size="small"
+                title="查看"
+                @click="openRuleDialog('view', scope.row)"
+              >
+                <el-icon><View /></el-icon>
+              </el-button>
+              <el-button
+                type="danger"
+                circle
+                size="small"
+                title="删除"
+                @click="removeRule(scope.$index)"
+              >
+                <el-icon><Delete /></el-icon>
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div class="add-rule-row">
+          <el-button
+            type="primary"
+            circle
+            @click="openRuleDialog('add')"
+          >
+            <el-icon><Plus /></el-icon>
+          </el-button>
+        </div>
+      </div>
+
+      <!-- 操作按钮 -->
+      <div class="footer">
+        <el-button v-if="step === 0" @click="handleCancel">取消</el-button>
+        <el-button v-if="step === 1" @click="step = 0">上一步</el-button>
+        <el-button v-if="step === 0" type="primary" @click="handleNext">下一步</el-button>
+        <el-button v-if="step === 1" type="primary" @click="handleSave">完成</el-button>
+      </div>
+    </el-card>
+
+    <rule-edit-dialog
+      v-model:visible="dialog.visible"
+      :rule="dialog.rule"
+      :mode="dialog.mode"
+      @save="onRuleSave"
+    />
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import ruleGroupStore from '@/store/ruleGroupStore'
+import {
+  GROUP_TYPES,
+  getMetaRule,
+  buildRuleDescription,
+  buildRuleParams
+} from '@/config/metaRules'
+import RuleEditDialog from '@/components/ruleGroup/RuleEditDialog.vue'
+
+export default {
+  name: 'RuleGroupEdit',
+  components: { RuleEditDialog },
+  data() {
+    return {
+      step: 0,
+      form: {
+        name: '',
+        type: '',
+        logic: 'AND',
+        description: '',
+        rules: []
+      },
+      rules: {
+        name: [
+          { required: true, message: '请输入规则组名称', trigger: 'blur' },
+          { max: 40, message: '最长 40 字符', trigger: 'blur' }
+        ],
+        type: [{ required: true, message: '请选择规则组类型', trigger: 'change' }],
+        logic: [{ required: true, message: '请选择规则逻辑', trigger: 'change' }]
+      },
+      groupTypes: GROUP_TYPES,
+      current: null,
+      dialog: { visible: false, mode: 'add', rule: null, editIndex: -1 }
+    }
+  },
+  computed: {
+    isEdit() {
+      return !!this.$route.params.id
+    }
+  },
+  created() {
+    if (this.isEdit) {
+      const target = ruleGroupStore.getById(this.$route.params.id)
+      if (!target) {
+        ElMessage.error('规则组不存在')
+        this.$router.replace('/customization/hsbc/rule-groups')
+        return
+      }
+      if (target.status === 'online') {
+        ElMessage.warning('已上线规则组不可编辑')
+        this.$router.replace(`/customization/hsbc/rule-groups/view/${target.id}`)
+        return
+      }
+      this.current = target
+      this.form = {
+        name: target.name,
+        type: target.type,
+        logic: target.logic,
+        description: target.description,
+        rules: JSON.parse(JSON.stringify(target.rules || []))
+      }
+    }
+  },
+  methods: {
+    metaName(code) {
+      const m = getMetaRule(code)
+      return m ? m.name : code
+    },
+    buildDesc(rule) {
+      return buildRuleDescription(rule.metaCode, rule.config) || '-'
+    },
+    buildParam(rule) {
+      return buildRuleParams(rule.metaCode, rule.config) || '-'
+    },
+    handleNext() {
+      this.$refs.baseForm.validate((ok) => {
+        if (ok) this.step = 1
+      })
+    },
+    openRuleDialog(mode, rule = null, index = -1) {
+      this.dialog = {
+        visible: true,
+        mode,
+        rule: rule ? JSON.parse(JSON.stringify(rule)) : null,
+        editIndex: index
+      }
+    },
+    onRuleSave(rule) {
+      if (this.dialog.mode === 'edit' && this.dialog.editIndex >= 0) {
+        this.form.rules[this.dialog.editIndex] = rule
+        ElMessage.success('已更新规则')
+      } else {
+        this.form.rules.push(rule)
+        ElMessage.success('已添加规则')
+      }
+    },
+    removeRule(idx) {
+      ElMessageBox.confirm('确定要删除该条规则吗?', '提示', {
+        confirmButtonText: '删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.form.rules.splice(idx, 1)
+      }).catch(() => {})
+    },
+    handleSave() {
+      if (this.form.rules.length === 0) {
+        ElMessage.error('请至少添加一条规则')
+        return
+      }
+      if (this.isEdit) {
+        ruleGroupStore.update(this.current.id, this.form)
+        ElMessage.success('保存成功')
+      } else {
+        ruleGroupStore.create(this.form)
+        ElMessage.success('创建成功')
+      }
+      this.$router.push('/customization/hsbc/rule-groups')
+    },
+    handleCancel() {
+      this.$router.push('/customization/hsbc/rule-groups')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.rule-group-edit { padding: 0; }
+.header { display: flex; align-items: center; justify-content: space-between; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+.rg-id { color: #909399; font-size: 13px; }
+
+.steps { margin: 16px 0 32px; }
+
+.section { margin-bottom: 24px; }
+.section-title {
+  font-size: 14px; font-weight: 600; color: #303133;
+  background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
+  margin-bottom: 16px;
+}
+.section-tip { font-weight: normal; color: #909399; margin-left: 12px; font-size: 12px; }
+
+.add-rule-row {
+  display: flex; align-items: center;
+  padding: 16px 0;
+}
+
+.params-text {
+  font-family: 'Menlo', 'Consolas', monospace;
+  color: #606266; font-size: 12px;
+}
+
+.footer { text-align: center; margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebeef5; }
+.footer .el-button { min-width: 100px; }
+</style>

+ 180 - 0
pc-v3/src/views/RuleGroupList.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="rule-group-list">
+    <el-card>
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="100px" inline>
+            <el-form-item label="规则组编号:">
+              <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="规则组名称:">
+              <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="规则组类型:">
+              <el-select v-model="searchForm.type" clearable placeholder="全部" style="width: 160px">
+                <el-option
+                  v-for="opt in groupTypes"
+                  :key="opt.value"
+                  :label="opt.label"
+                  :value="opt.value"
+                />
+              </el-select>
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" @click="handleCreate">
+            <el-icon><Plus /></el-icon>新增规则组
+          </el-button>
+        </div>
+        </div>
+      </template>
+
+      <el-table :data="pagedData" stripe border style="width: 100%">
+        <el-table-column label="操作" width="220" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleView(scope.row)">查看详情</el-button>
+            <template v-if="scope.row.status === 'draft'">
+              <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+              <el-button type="text" size="small" @click="handlePublish(scope.row)">提交审核</el-button>
+              <el-button
+                type="text"
+                size="small"
+                style="color: #F56C6C"
+                @click="handleDelete(scope.row)"
+              >删除</el-button>
+            </template>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="规则组编号" width="170" />
+        <el-table-column prop="name" label="规则组名称" min-width="180" show-overflow-tooltip />
+        <el-table-column label="规则组类型" width="130">
+          <template #default="scope">{{ typeLabel(scope.row.type) }}</template>
+        </el-table-column>
+        <el-table-column label="规则逻辑" width="110" align="center">
+          <template #default="scope">
+            <el-tag size="small" :type="scope.row.logic === 'AND' ? '' : 'warning'">
+              {{ scope.row.logic === 'AND' ? '且(AND)' : '或(OR)' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="规则数量" width="90" align="center">
+          <template #default="scope">{{ (scope.row.rules || []).length }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="100" align="center">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === 'online' ? 'success' : 'info'" size="small">
+              {{ scope.row.status === 'online' ? '已上线' : '新建' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator" label="创建人" width="100" />
+        <el-table-column prop="createdAt" label="创建时间" width="170" />
+      </el-table>
+
+      <div class="pagination-section">
+        <span class="total">共 {{ filteredData.length }} 条</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          v-model:current-page="page.current"
+          v-model:page-size="page.size"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredData.length"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import ruleGroupStore from '@/store/ruleGroupStore'
+import { GROUP_TYPES } from '@/config/metaRules'
+
+export default {
+  name: 'RuleGroupList',
+  data() {
+    return {
+      searchForm: { id: '', name: '', type: '' },
+      page: { current: 1, size: 10 },
+      groupTypes: GROUP_TYPES
+    }
+  },
+  computed: {
+    filteredData() {
+      return ruleGroupStore.query(this.searchForm)
+    },
+    pagedData() {
+      const start = (this.page.current - 1) * this.page.size
+      return this.filteredData.slice(start, start + this.page.size)
+    }
+  },
+  methods: {
+    typeLabel(type) {
+      const item = this.groupTypes.find((g) => g.value === type)
+      return item ? item.label : type
+    },
+    handleSearch() {
+      this.page.current = 1
+    },
+    handleReset() {
+      this.searchForm = { id: '', name: '', type: '' }
+      this.page.current = 1
+    },
+    handleCreate() {
+      this.$router.push('/customization/hsbc/rule-groups/add')
+    },
+    handleView(row) {
+      this.$router.push(`/customization/hsbc/rule-groups/view/${row.id}`)
+    },
+    handleEdit(row) {
+      this.$router.push(`/customization/hsbc/rule-groups/edit/${row.id}`)
+    },
+    handlePublish(row) {
+      ElMessageBox.confirm(`确定要提交规则组「${row.name}」审核吗?审核通过后将不可编辑/删除。`, '提示', {
+        confirmButtonText: '提交',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        ruleGroupStore.publish(row.id)
+        ElMessage.success('已提交审核(demo 默认通过)')
+      }).catch(() => {})
+    },
+    handleDelete(row) {
+      ElMessageBox.confirm(`确定要删除规则组「${row.name}」吗?删除后无法恢复`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        ruleGroupStore.remove(row.id)
+        ElMessage.success('删除成功')
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.rule-group-list { padding: 0; }
+
+.search-section { display: flex; flex-direction: column; }
+.search-row .el-form { display: flex; flex-wrap: wrap; gap: 8px 0; }
+.search-row .el-form-item { margin-right: 8px; margin-bottom: 8px; }
+
+.button-group { display: flex; gap: 5px; margin-top: 2px; }
+.action-row { margin-top: 12px; }
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+.pagination-section .total { color: #606266; font-size: 13px; }
+</style>

+ 130 - 0
pc-v3/src/views/RuleGroupView.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="rule-group-view">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">客群规则任务详情</span>
+          <span v-if="current" class="rg-id">编号:{{ current.id }}</span>
+        </div>
+      </template>
+
+      <div v-if="current" class="section">
+        <div class="section-title">基本信息</div>
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="规则组编号">{{ current.id }}</el-descriptions-item>
+          <el-descriptions-item label="规则组名称">{{ current.name }}</el-descriptions-item>
+          <el-descriptions-item label="规则组类型">{{ typeLabel(current.type) }}</el-descriptions-item>
+          <el-descriptions-item label="规则逻辑">
+            <el-tag size="small" :type="current.logic === 'AND' ? '' : 'warning'">
+              {{ current.logic === 'AND' ? '且(AND)' : '或(OR)' }}
+            </el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="current.status === 'online' ? 'success' : 'info'" size="small">
+              {{ current.status === 'online' ? '已上线' : '新建' }}
+            </el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="规则数量">{{ (current.rules || []).length }}</el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ current.creator }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ current.createdAt }}</el-descriptions-item>
+          <el-descriptions-item label="最后更新人">{{ current.updater }}</el-descriptions-item>
+          <el-descriptions-item label="最后更新时间">{{ current.updatedAt }}</el-descriptions-item>
+          <el-descriptions-item label="备注说明" :span="2">
+            {{ current.description || '-' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <div v-if="current" class="section">
+        <div class="section-title">
+          规则组配置
+          <span class="section-tip">规则之间使用「{{ current.logic === 'AND' ? '且' : '或' }}」逻辑组合</span>
+        </div>
+        <el-table :data="current.rules || []" border style="width: 100%" empty-text="暂无规则">
+          <el-table-column prop="metaCode" label="元规则编号" width="110" align="center" />
+          <el-table-column label="元规则名称" min-width="180">
+            <template #default="scope">{{ metaName(scope.row.metaCode) }}</template>
+          </el-table-column>
+          <el-table-column prop="ruleName" label="规则名称" min-width="160" show-overflow-tooltip />
+          <el-table-column label="规则描述" min-width="240" show-overflow-tooltip>
+            <template #default="scope">{{ buildDesc(scope.row) }}</template>
+          </el-table-column>
+          <el-table-column label="规则参数" min-width="220" show-overflow-tooltip>
+            <template #default="scope">
+              <span class="params-text">{{ buildParam(scope.row) }}</span>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <div class="footer">
+        <el-button @click="$router.push('/customization/hsbc/rule-groups')">返回</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage } from 'element-plus'
+import ruleGroupStore from '@/store/ruleGroupStore'
+import {
+  GROUP_TYPES,
+  getMetaRule,
+  buildRuleDescription,
+  buildRuleParams
+} from '@/config/metaRules'
+
+export default {
+  name: 'RuleGroupView',
+  data() {
+    return { current: null }
+  },
+  created() {
+    const target = ruleGroupStore.getById(this.$route.params.id)
+    if (!target) {
+      ElMessage.error('规则组不存在')
+      this.$router.replace('/customization/hsbc/rule-groups')
+      return
+    }
+    this.current = target
+  },
+  methods: {
+    typeLabel(type) {
+      const item = GROUP_TYPES.find((g) => g.value === type)
+      return item ? item.label : type
+    },
+    metaName(code) {
+      const m = getMetaRule(code)
+      return m ? m.name : code
+    },
+    buildDesc(rule) {
+      return buildRuleDescription(rule.metaCode, rule.config) || '-'
+    },
+    buildParam(rule) {
+      return buildRuleParams(rule.metaCode, rule.config) || '-'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.rule-group-view { padding: 0; }
+.header { display: flex; align-items: center; justify-content: space-between; }
+.title { font-size: 16px; font-weight: 600; color: #409EFF; }
+.rg-id { color: #909399; font-size: 13px; }
+
+.section { margin-bottom: 24px; }
+.section-title {
+  font-size: 14px; font-weight: 600; color: #303133;
+  background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
+  margin-bottom: 16px;
+}
+.section-tip { font-weight: normal; color: #909399; margin-left: 12px; font-size: 12px; }
+
+.params-text {
+  font-family: 'Menlo', 'Consolas', monospace;
+  color: #606266; font-size: 12px;
+}
+
+.footer { text-align: center; margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebeef5; }
+</style>

+ 237 - 0
pc-v3/src/views/SmsSendRecord.vue

@@ -0,0 +1,237 @@
+<template>
+  <div class="sms-send-record">
+    <el-card>
+      <!-- 搜索区域 -->
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="100px" inline>
+            <el-form-item label="接收手机号:">
+              <el-input v-model="searchForm.mobile" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="模板名称:">
+              <el-select v-model="searchForm.templateId" clearable placeholder="全部" style="width: 200px">
+                <el-option
+                  v-for="item in templateOptions"
+                  :key="item.id"
+                  :label="item.name"
+                  :value="item.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="发送状态:">
+              <el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 120px">
+                <el-option label="成功" value="success" />
+                <el-option label="失败" value="failed" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="发送时间:">
+              <el-date-picker
+                v-model="searchForm.sentRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="yyyy-MM-dd"
+                style="width: 260px"
+              />
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        </div>
+      </template>
+
+      <!-- 表格 -->
+      <el-table :data="pagedData" stripe style="width: 100%" border>
+        <el-table-column prop="id" label="发送 ID" width="180" />
+        <el-table-column prop="mobile" label="接收手机号" width="140" />
+        <el-table-column prop="templateName" label="模板名称" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="content" label="发送内容" min-width="320" show-overflow-tooltip />
+        <el-table-column prop="productName" label="关联权益商品" width="160" show-overflow-tooltip />
+        <el-table-column label="发送状态" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === 'success' ? 'success' : 'danger'" size="small">
+              {{ scope.row.status === 'success' ? '成功' : '失败' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+<el-table-column prop="sentAt" label="发送时间" width="160" />
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-section">
+        <span class="total">共 {{ filteredData.length }} 条</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          v-model:current-page="page.current"
+          v-model:page-size="page.size"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredData.length"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import smsTemplateStore from '@/store/smsTemplateStore'
+
+export default {
+  name: 'SmsSendRecord',
+  data() {
+    return {
+      searchForm: {
+        mobile: '',
+        templateId: '',
+        status: '',
+        sentRange: []
+      },
+      page: { current: 1, size: 10 },
+      records: [
+        {
+          id: 'SMS20260510001',
+          mobile: '138****5821',
+          templateId: 'SMS_TPL_001',
+          templateName: '通用权益支付成功模板',
+          content: '【银行】您购买的星巴克电子券已支付成功,卡号:8821****3340,卡密:xc**29,有效期至2026-08-31,详情:https://m.bank.com/c/8821',
+          productName: '星巴克电子券',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-10 14:08:21'
+        },
+        {
+          id: 'SMS20260510002',
+          mobile: '139****0173',
+          templateId: 'SMS_TPL_002',
+          templateName: '券码类短信模板',
+          content: '【银行】尊敬的客户,您的肯德基早餐券购买成功,券码为KFC8821X,请于2026-06-30前使用。',
+          productName: '肯德基早餐券',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-10 15:22:09'
+        },
+        {
+          id: 'SMS20260510003',
+          mobile: '186****4502',
+          templateId: 'SMS_TPL_001',
+          templateName: '通用权益支付成功模板',
+          content: '【银行】您购买的爱奇艺会员月卡已支付成功,卡号:QY002****9981,卡密:iq**aa,有效期至2026-06-10,详情:https://m.bank.com/c/qy002',
+          productName: '爱奇艺会员月卡',
+          status: 'failed',
+          failReason: '号码格式错误',
+          sentAt: '2026-05-10 16:01:55'
+        },
+        {
+          id: 'SMS20260509001',
+          mobile: '135****7714',
+          templateId: 'SMS_TPL_003',
+          templateName: '实物类短信模板',
+          content: '【银行】您购买的小米保温杯已受理,我们将尽快安排发货,感谢您的支持。',
+          productName: '小米保温杯',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-09 10:45:33'
+        },
+        {
+          id: 'SMS20260509002',
+          mobile: '137****3398',
+          templateId: 'SMS_TPL_002',
+          templateName: '券码类短信模板',
+          content: '【银行】尊敬的客户,您的麦当劳套餐券购买成功,券码为MD0089Z,请于2026-07-31前使用。',
+          productName: '麦当劳套餐券',
+          status: 'failed',
+          failReason: '运营商通道拥塞',
+          sentAt: '2026-05-09 18:12:48'
+        },
+        {
+          id: 'SMS20260508001',
+          mobile: '152****6024',
+          templateId: 'SMS_TPL_001',
+          templateName: '通用权益支付成功模板',
+          content: '【银行】您购买的腾讯视频季卡已支付成功,卡号:TX007****4412,卡密:vp**xz,有效期至2026-08-08,详情:https://m.bank.com/c/tx007',
+          productName: '腾讯视频季卡',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-08 09:33:12'
+        },
+        {
+          id: 'SMS20260508002',
+          mobile: '188****1156',
+          templateId: 'SMS_TPL_003',
+          templateName: '实物类短信模板',
+          content: '【银行】您购买的网易严选毛巾已受理,我们将尽快安排发货,感谢您的支持。',
+          productName: '网易严选毛巾',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-08 13:47:50'
+        },
+        {
+          id: 'SMS20260507001',
+          mobile: '177****8809',
+          templateId: 'SMS_TPL_002',
+          templateName: '券码类短信模板',
+          content: '【银行】尊敬的客户,您的喜茶饮品券购买成功,券码为HC8821D,请于2026-06-15前使用。',
+          productName: '喜茶饮品券',
+          status: 'success',
+          failReason: '',
+          sentAt: '2026-05-07 11:09:22'
+        }
+      ]
+    }
+  },
+  computed: {
+    templateOptions() {
+      return smsTemplateStore.state.list.map(item => ({ id: item.id, name: item.name }))
+    },
+    filteredData() {
+      const { mobile, templateId, status, sentRange } = this.searchForm
+      return this.records.filter(row => {
+        if (mobile && !row.mobile.includes(mobile)) return false
+        if (templateId && row.templateId !== templateId) return false
+        if (status && row.status !== status) return false
+        if (sentRange && sentRange.length === 2) {
+          const day = row.sentAt.slice(0, 10)
+          if (day < sentRange[0] || day > sentRange[1]) return false
+        }
+        return true
+      })
+    },
+    pagedData() {
+      const start = (this.page.current - 1) * this.page.size
+      return this.filteredData.slice(start, start + this.page.size)
+    }
+  },
+  methods: {
+    handleSearch() {
+      this.page.current = 1
+    },
+    handleReset() {
+      this.searchForm = { mobile: '', templateId: '', status: '', sentRange: [] }
+      this.page.current = 1
+    }
+  }
+}
+</script>
+
+<style scoped>
+.sms-send-record { padding: 0; }
+
+.search-section { display: flex; flex-direction: column; }
+.search-row .el-form { display: flex; flex-wrap: wrap; gap: 8px 0; }
+.search-row .el-form-item { margin-right: 8px; margin-bottom: 8px; }
+
+.button-group { display: flex; gap: 5px; margin-top: 2px; }
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+.pagination-section .total { color: #606266; font-size: 13px; }
+</style>

+ 267 - 0
pc-v3/src/views/SmsTemplateList.vue

@@ -0,0 +1,267 @@
+<template>
+  <div class="sms-template-list">
+    <el-card>
+      <!-- 搜索区域 -->
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="100px" inline>
+            <el-form-item label="模板名称:">
+              <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="模板 ID:">
+              <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" @click="handleCreate">
+            <el-icon><Plus /></el-icon>新建短信模板
+          </el-button>
+        </div>
+        </div>
+      </template>
+
+      <!-- 表格 -->
+      <el-table :data="pagedData" stripe style="width: 100%" border>
+        <el-table-column label="操作" width="140" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button
+              type="text"
+              size="small"
+              style="color: #F56C6C"
+              @click="handleDelete(scope.row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="模板 ID" width="160" />
+        <el-table-column prop="name" label="模板名称" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="content" label="模板内容" min-width="320" show-overflow-tooltip />
+        <el-table-column prop="creator" label="创建人" width="100" />
+        <el-table-column prop="createdAt" label="创建时间" width="160" />
+        <el-table-column prop="updater" label="最后更新人" width="110" />
+        <el-table-column prop="updatedAt" label="更新时间" width="160" />
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-section">
+        <span class="total">共 {{ filteredData.length }} 条</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          v-model:current-page="page.current"
+          v-model:page-size="page.size"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredData.length"
+        />
+      </div>
+    </el-card>
+
+    <!-- 添加/编辑弹窗 -->
+    <el-dialog
+      :title="dialog.mode === 'create' ? '新建短信模板' : '编辑短信模板'"
+      v-model="dialog.visible"
+      width="600px"
+      :close-on-click-modal="false"
+      @closed="resetForm"
+    >
+      <el-form
+        ref="form"
+        :model="form"
+        :rules="rules"
+        label-width="100px"
+      >
+        <el-form-item label="模板名称" prop="name">
+          <el-input
+            v-model="form.name"
+            maxlength="40"
+            show-word-limit
+            placeholder="请输入模板名称"
+          />
+        </el-form-item>
+        <el-form-item label="模板内容" prop="content">
+          <el-input
+            v-model="form.content"
+            type="textarea"
+            :rows="6"
+            maxlength="500"
+            show-word-limit
+            placeholder="请输入模板内容,支持下方占位符"
+          />
+          <div class="placeholder-tip">
+            <div class="placeholder-tip__title">占位符说明:</div>
+            <div class="placeholder-tip__item">
+              <span class="placeholder-tip__label">商品名称:</span>
+              <code>${productName}</code>
+            </div>
+            <div class="placeholder-tip__item">
+              <span class="placeholder-tip__label">结束日期:</span>
+              <code>${expireDate}</code>
+            </div>
+            <div class="placeholder-tip__item">
+              <span class="placeholder-tip__label">卡号:</span>
+              <code>${exchangeNum}</code>
+            </div>
+            <div class="placeholder-tip__item">
+              <span class="placeholder-tip__label">卡密:</span>
+              <code>${exchangePwd}</code>
+            </div>
+            <div class="placeholder-tip__item">
+              <span class="placeholder-tip__label">券码路径:</span>
+              <code>${qrCodeDownPaths}</code>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialog.visible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import smsTemplateStore from '@/store/smsTemplateStore'
+
+export default {
+  name: 'SmsTemplateList',
+  data() {
+    return {
+      searchForm: {
+        name: '',
+        id: ''
+      },
+      page: { current: 1, size: 10 },
+      dialog: {
+        visible: false,
+        mode: 'create',
+        editingId: ''
+      },
+      form: {
+        name: '',
+        content: ''
+      },
+      rules: {
+        name: [
+          { required: true, message: '请输入模板名称', trigger: 'blur' },
+          { max: 40, message: '不超过 40 字', trigger: 'blur' }
+        ],
+        content: [
+          { required: true, message: '请输入模板内容', trigger: 'blur' },
+          { max: 500, message: '不超过 500 字', trigger: 'blur' }
+        ]
+      }
+    }
+  },
+  computed: {
+    filteredData() {
+      return smsTemplateStore.query(this.searchForm)
+    },
+    pagedData() {
+      const start = (this.page.current - 1) * this.page.size
+      return this.filteredData.slice(start, start + this.page.size)
+    }
+  },
+  methods: {
+    handleSearch() {
+      this.page.current = 1
+    },
+    handleReset() {
+      this.searchForm = { name: '', id: '' }
+      this.page.current = 1
+    },
+    handleCreate() {
+      this.dialog.mode = 'create'
+      this.dialog.editingId = ''
+      this.form = { name: '', content: '' }
+      this.dialog.visible = true
+    },
+    handleEdit(row) {
+      this.dialog.mode = 'edit'
+      this.dialog.editingId = row.id
+      this.form = { name: row.name, content: row.content }
+      this.dialog.visible = true
+    },
+    handleDelete(row) {
+      ElMessageBox.confirm(`确定要删除模板「${row.name}」吗?删除后无法恢复`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        smsTemplateStore.remove(row.id)
+        ElMessage.success('删除成功')
+      }).catch(() => {})
+    },
+    handleSave() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        if (this.dialog.mode === 'create') {
+          smsTemplateStore.create({ name: this.form.name, content: this.form.content })
+          ElMessage.success('新建成功')
+        } else {
+          smsTemplateStore.update(this.dialog.editingId, { name: this.form.name, content: this.form.content })
+          ElMessage.success('保存成功')
+        }
+        this.dialog.visible = false
+      })
+    },
+    resetForm() {
+      if (this.$refs.form) this.$refs.form.clearValidate()
+      this.form = { name: '', content: '' }
+      this.dialog.editingId = ''
+    }
+  }
+}
+</script>
+
+<style scoped>
+.sms-template-list { padding: 0; }
+
+.search-section { display: flex; flex-direction: column; }
+.search-row .el-form { display: flex; flex-wrap: wrap; gap: 8px 0; }
+.search-row .el-form-item { margin-right: 8px; margin-bottom: 8px; }
+
+.button-group { display: flex; gap: 5px; margin-top: 2px; }
+.action-row { margin-top: 12px; }
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+.pagination-section .total { color: #606266; font-size: 13px; }
+
+.placeholder-tip {
+  margin-top: 10px;
+  padding: 12px 14px;
+  background: #f5f7fa;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 12px;
+  color: #606266;
+  line-height: 1.8;
+}
+.placeholder-tip__title {
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 4px;
+}
+.placeholder-tip__item { display: flex; align-items: center; gap: 4px; }
+.placeholder-tip__label { color: #606266; }
+.placeholder-tip code {
+  background: #fff;
+  padding: 1px 6px;
+  border: 1px solid #ebeef5;
+  border-radius: 3px;
+  color: #409EFF;
+  font-family: Menlo, Consolas, monospace;
+}
+</style>

+ 263 - 0
pc-v3/src/views/TagEdit.vue

@@ -0,0 +1,263 @@
+<template>
+  <div class="tag-edit">
+    <el-card>
+      <template #header>
+        <div class="header">
+          <span class="title">{{ isEdit ? '编辑标签' : '新建标签' }}</span>
+          <span v-if="isEdit && currentTag" class="tag-id">ID: {{ currentTag.id }}</span>
+        </div>
+      </template>
+
+      <!-- 基础信息 -->
+      <div class="section">
+        <div class="section-title">基础信息</div>
+        <el-form
+          ref="form"
+          :model="form"
+          :rules="rules"
+          label-width="100px"
+          style="max-width: 760px"
+        >
+          <el-form-item label="标签名称" prop="name">
+            <el-input v-model="form.name" placeholder="最长 40 字符" maxlength="40" show-word-limit />
+          </el-form-item>
+          <el-form-item label="标签描述" prop="description">
+            <el-input
+              v-model="form.description"
+              type="textarea"
+              :rows="3"
+              placeholder="便于识别标签用途,最长 200 字符"
+              maxlength="200"
+              show-word-limit
+            />
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-switch v-model="form.status" active-text="启用" inactive-text="停用" />
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <!-- 规则配置 -->
+      <div class="section">
+        <div class="section-title">规则配置</div>
+        <rule-builder v-model="form.groups" />
+      </div>
+
+      <!-- 操作按钮 -->
+      <div class="footer">
+        <el-button @click="handleCancel">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import tagStore from '@/store/tagStore'
+import RuleBuilder from '@/components/tag/RuleBuilder.vue'
+import { PARAM_MAP, needValueInput } from '@/config/tagParams'
+
+export default {
+  name: 'TagEdit',
+  components: { RuleBuilder },
+  data() {
+    return {
+      form: {
+        name: '',
+        description: '',
+        status: true,
+        groups: []
+      },
+      rules: {
+        name: [
+          { required: true, message: '请输入标签名称', trigger: 'blur' },
+          { max: 40, message: '最长 40 字符', trigger: 'blur' }
+        ],
+        description: [
+          { max: 200, message: '最长 200 字符', trigger: 'blur' }
+        ]
+      },
+      currentTag: null,
+      initialSnapshot: ''
+    }
+  },
+  computed: {
+    isEdit() {
+      return !!this.$route.params.id
+    }
+  },
+  created() {
+    if (this.isEdit) {
+      const target = tagStore.getById(this.$route.params.id)
+      if (!target) {
+        ElMessage.error('标签不存在')
+        this.$router.replace('/customization/hsbc/tags')
+        return
+      }
+      this.currentTag = target
+      this.form = {
+        name: target.name,
+        description: target.description,
+        status: target.status === 'enabled',
+        groups: JSON.parse(JSON.stringify(target.groups || []))
+      }
+    }
+    this.initialSnapshot = JSON.stringify(this.form)
+  },
+  methods: {
+    validateRules() {
+      if (!this.form.groups.length) {
+        ElMessage.error('至少配置 1 个规则组')
+        return false
+      }
+      for (let i = 0; i < this.form.groups.length; i++) {
+        const group = this.form.groups[i]
+        if (!group.rules || !group.rules.length) {
+          ElMessage.error(`规则组 ${i + 1} 至少配置 1 条规则`)
+          return false
+        }
+        for (let j = 0; j < group.rules.length; j++) {
+          const rule = group.rules[j]
+          const label = `规则组 ${i + 1} 第 ${j + 1} 条规则`
+          if (!rule.paramKey) {
+            ElMessage.error(`${label}:请选择参数`)
+            return false
+          }
+          if (!rule.operator) {
+            ElMessage.error(`${label}:请选择运算符`)
+            return false
+          }
+          if (needValueInput(rule.operator)) {
+            const param = PARAM_MAP[rule.paramKey]
+            // 相对日期:需要 N 值和单位
+            if (['withinLast', 'beforeLast'].includes(rule.operator)) {
+              if (rule.value === '' || rule.value === null || rule.value === undefined) {
+                ElMessage.error(`${label}:请输入相对日期数值`)
+                return false
+              }
+            } else if (Array.isArray(rule.value)) {
+              if (!rule.value.length) {
+                ElMessage.error(`${label}:请选择比较值`)
+                return false
+              }
+            } else if (rule.value === '' || rule.value === null || rule.value === undefined) {
+              ElMessage.error(`${label}:请输入比较值`)
+              return false
+            }
+
+            if (rule.operator === 'between') {
+              if (rule.value2 === '' || rule.value2 === null || rule.value2 === undefined) {
+                ElMessage.error(`${label}:请输入区间上限`)
+                return false
+              }
+              if (param && (param.type === 'BigDecimal' || param.type === 'List')
+                  && Number(rule.value) > Number(rule.value2)) {
+                ElMessage.error(`${label}:区间下限不能大于上限`)
+                return false
+              }
+            }
+          }
+          // 时间窗口校验
+          if (param_hasHistory(rule.paramKey)) {
+            const tw = rule.timeWindow || {}
+            if (tw.mode === 'specificMonth' && !tw.month) {
+              ElMessage.error(`${label}:请选择指定月份`)
+              return false
+            }
+            if (tw.mode === 'recentMonths') {
+              if (!tw.n || tw.n < 1 || tw.n > 12) {
+                ElMessage.error(`${label}:近 N 个月 的 N 需在 1-12 之间`)
+                return false
+              }
+              if (tw.aggregator === 'atLeast') {
+                if (!tw.m || tw.m < 1 || tw.m > tw.n) {
+                  ElMessage.error(`${label}:至少 M 个月 的 M 不能大于 N`)
+                  return false
+                }
+              }
+            }
+          }
+        }
+      }
+      return true
+    },
+    handleSave() {
+      this.$refs.form.validate((valid) => {
+        if (!valid) return
+        if (!this.validateRules()) return
+
+        const doSave = () => {
+          if (this.isEdit) {
+            tagStore.update(this.currentTag.id, this.form)
+            ElMessage.success('保存成功')
+          } else {
+            tagStore.create(this.form)
+            ElMessage.success('新建成功')
+          }
+          this.$router.push('/customization/hsbc/tags')
+        }
+
+        if (this.isEdit && this.currentTag && this.currentTag.referenceCount > 0) {
+          ElMessageBox.confirm(
+            `该标签已被 ${this.currentTag.referenceCount} 个权益/活动引用,修改后将影响其客群匹配,确认保存?`,
+            '提示',
+            { type: 'warning' }
+          ).then(doSave).catch(() => {})
+        } else {
+          doSave()
+        }
+      })
+    },
+    handleCancel() {
+      const dirty = JSON.stringify(this.form) !== this.initialSnapshot
+      if (dirty) {
+        ElMessageBox.confirm('未保存内容将丢失,确认退出?', '提示', { type: 'warning' })
+          .then(() => this.$router.push('/customization/hsbc/tags'))
+          .catch(() => {})
+      } else {
+        this.$router.push('/customization/hsbc/tags')
+      }
+    }
+  }
+}
+
+function param_hasHistory(key) {
+  const p = PARAM_MAP[key]
+  return p && p.hasHistory
+}
+</script>
+
+<style scoped>
+.tag-edit { padding: 0; }
+
+.header {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+.title { font-size: 16px; font-weight: 600; }
+.tag-id { color: #909399; font-size: 13px; }
+
+.section {
+  padding: 16px 0 20px;
+  border-bottom: 1px dashed #ebeef5;
+}
+.section:last-of-type { border-bottom: none; }
+
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 16px;
+  padding-left: 8px;
+  border-left: 3px solid #409EFF;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  gap: 16px;
+  padding-top: 20px;
+}
+</style>

+ 206 - 0
pc-v3/src/views/TagList.vue

@@ -0,0 +1,206 @@
+<template>
+  <div class="tag-list">
+    <el-card>
+      <!-- 搜索区域 -->
+      <template #header>
+        <div class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="90px" inline>
+            <el-form-item label="标签名称:">
+              <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width: 160px" />
+            </el-form-item>
+            <el-form-item label="标签 ID:">
+              <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width: 180px" />
+            </el-form-item>
+            <el-form-item label="状态:">
+              <el-select v-model="searchForm.status" clearable placeholder="全部" style="width: 120px">
+                <el-option label="启用" value="enabled" />
+                <el-option label="停用" value="disabled" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="创建人:">
+              <el-input v-model="searchForm.creator" placeholder="请输入" clearable style="width: 140px" />
+            </el-form-item>
+            <el-form-item label="创建时间:">
+              <el-date-picker
+                v-model="searchForm.createdRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="yyyy-MM-dd"
+                style="width: 260px"
+              />
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" @click="handleCreate">
+            <el-icon><Plus /></el-icon>新建标签
+          </el-button>
+        </div>
+        </div>
+      </template>
+
+      <!-- 表格 -->
+      <el-table :data="pagedData" stripe style="width: 100%" border>
+        <el-table-column label="操作" width="200" fixed="left">
+          <template #default="scope">
+            <el-button type="text" size="small" @click="handleView(scope.row)">查看</el-button>
+            <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button
+              type="text"
+              size="small"
+              @click="handleToggle(scope.row)"
+            >{{ scope.row.status === 'enabled' ? '停用' : '启用' }}</el-button>
+            <el-button
+              type="text"
+              size="small"
+              style="color: #F56C6C"
+              @click="handleDelete(scope.row)"
+            >删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="标签 ID" width="160" />
+        <el-table-column prop="name" label="标签名称" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="description" label="标签描述" min-width="200" show-overflow-tooltip />
+        <el-table-column label="规则摘要" width="110">
+          <template #default="scope">
+            {{ ruleSummary(scope.row) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="90">
+          <template #default="scope">
+            <el-tag :type="scope.row.status === 'enabled' ? 'success' : 'info'" size="small">
+              {{ scope.row.status === 'enabled' ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="referenceCount" label="引用数" width="80" align="center" />
+        <el-table-column prop="creator" label="创建人" width="100" />
+        <el-table-column prop="createdAt" label="创建时间" width="160" />
+        <el-table-column prop="updater" label="最后更新人" width="110" />
+        <el-table-column prop="updatedAt" label="更新时间" width="160" />
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-section">
+        <span class="total">共 {{ filteredData.length }} 条</span>
+        <el-pagination
+          layout="prev, pager, next, sizes, jumper"
+          v-model:current-page="page.current"
+          v-model:page-size="page.size"
+          :page-sizes="[10, 20, 50]"
+          :total="filteredData.length"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import tagStore from '@/store/tagStore'
+
+export default {
+  name: 'TagList',
+  data() {
+    return {
+      searchForm: {
+        name: '',
+        id: '',
+        status: '',
+        creator: '',
+        createdRange: []
+      },
+      page: { current: 1, size: 10 }
+    }
+  },
+  computed: {
+    filteredData() {
+      return tagStore.query(this.searchForm)
+    },
+    pagedData() {
+      const start = (this.page.current - 1) * this.page.size
+      return this.filteredData.slice(start, start + this.page.size)
+    }
+  },
+  methods: {
+    ruleSummary(row) {
+      const g = (row.groups || []).length
+      const r = (row.groups || []).reduce((sum, gr) => sum + (gr.rules ? gr.rules.length : 0), 0)
+      return `${g} 组 / ${r} 条`
+    },
+    handleSearch() {
+      this.page.current = 1
+    },
+    handleReset() {
+      this.searchForm = { name: '', id: '', status: '', creator: '', createdRange: [] }
+      this.page.current = 1
+    },
+    handleCreate() {
+      this.$router.push('/customization/hsbc/tags/add')
+    },
+    handleView(row) {
+      this.$router.push(`/customization/hsbc/tags/view/${row.id}`)
+    },
+    handleEdit(row) {
+      this.$router.push(`/customization/hsbc/tags/edit/${row.id}`)
+    },
+    handleToggle(row) {
+      if (row.status === 'enabled') {
+        ElMessageBox.confirm('停用后,引用该标签的业务将视为该客群不命中,确认停用?', '提示', { type: 'warning' })
+          .then(() => {
+            tagStore.toggleStatus(row.id)
+            ElMessage.success('已停用')
+          }).catch(() => {})
+      } else {
+        tagStore.toggleStatus(row.id)
+        ElMessage.success('已启用')
+      }
+    },
+    handleDelete(row) {
+      if (row.referenceCount > 0) {
+        ElMessageBox.alert(
+          `该标签已被 ${row.referenceCount} 个权益/活动引用,请先在对应业务中解除引用后再删除`,
+          '无法删除',
+          { confirmButtonText: '我知道了', type: 'warning' }
+        )
+        return
+      }
+      ElMessageBox.confirm(`确定要删除标签「${row.name}」吗?删除后无法恢复`, '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        tagStore.remove(row.id)
+        ElMessage.success('删除成功')
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tag-list { padding: 0; }
+
+.search-section { display: flex; flex-direction: column; }
+.search-row .el-form { display: flex; flex-wrap: wrap; gap: 8px 0; }
+.search-row .el-form-item { margin-right: 8px; margin-bottom: 8px; }
+
+.button-group { display: flex; gap: 5px; margin-top: 2px; }
+.action-row { margin-top: 12px; }
+
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+.pagination-section .total { color: #606266; font-size: 13px; }
+</style>

+ 106 - 0
pc-v3/src/views/TagView.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="tag-view">
+    <el-card v-if="tag">
+      <template #header>
+        <div class="header">
+          <span class="title">查看标签</span>
+          <span class="tag-id">ID: {{ tag.id }}</span>
+        </div>
+      </template>
+
+      <!-- 基础信息 -->
+      <div class="section">
+        <div class="section-title">基础信息</div>
+        <el-descriptions :column="2" border label-class-name="desc-label">
+          <el-descriptions-item label="标签名称">{{ tag.name }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="tag.status === 'enabled' ? 'success' : 'info'" size="small">
+              {{ tag.status === 'enabled' ? '启用' : '停用' }}
+            </el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="标签描述" :span="2">
+            {{ tag.description || '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="创建人">{{ tag.creator }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ tag.createdAt }}</el-descriptions-item>
+          <el-descriptions-item label="最后更新人">{{ tag.updater }}</el-descriptions-item>
+          <el-descriptions-item label="更新时间">{{ tag.updatedAt }}</el-descriptions-item>
+          <el-descriptions-item label="被引用次数">{{ tag.referenceCount }}</el-descriptions-item>
+          <el-descriptions-item label="规则摘要">{{ ruleSummary }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <!-- 规则(只读) -->
+      <div class="section">
+        <div class="section-title">规则配置</div>
+        <rule-builder :value="tag.groups" readonly />
+      </div>
+
+      <div class="footer">
+        <el-button @click="handleBack">返回</el-button>
+      </div>
+    </el-card>
+
+    <el-empty v-else description="标签不存在" />
+  </div>
+</template>
+
+<script>
+import tagStore from '@/store/tagStore'
+import RuleBuilder from '@/components/tag/RuleBuilder.vue'
+
+export default {
+  name: 'TagView',
+  components: { RuleBuilder },
+  data() {
+    return { tag: null }
+  },
+  computed: {
+    ruleSummary() {
+      if (!this.tag) return ''
+      const g = (this.tag.groups || []).length
+      const r = (this.tag.groups || []).reduce((sum, gr) => sum + (gr.rules ? gr.rules.length : 0), 0)
+      return `${g} 组 / ${r} 条`
+    }
+  },
+  created() {
+    this.tag = tagStore.getById(this.$route.params.id)
+  },
+  methods: {
+    handleBack() {
+      this.$router.push('/customization/hsbc/tags')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tag-view { padding: 0; }
+
+.header { display: flex; align-items: center; gap: 16px; }
+.title { font-size: 16px; font-weight: 600; }
+.tag-id { color: #909399; font-size: 13px; }
+
+.section {
+  padding: 16px 0 20px;
+  border-bottom: 1px dashed #ebeef5;
+}
+.section:last-of-type { border-bottom: none; }
+
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 16px;
+  padding-left: 8px;
+  border-left: 3px solid #409EFF;
+}
+
+.footer {
+  display: flex;
+  justify-content: center;
+  padding-top: 20px;
+}
+
+::v-deep .desc-label { width: 120px; }
+</style>

+ 854 - 0
pc-v3/src/views/ViewEquityProduct.vue

@@ -0,0 +1,854 @@
+<template>
+  <div class="view-equity-product">
+    <el-card>
+      <template #header>
+        <div class="header-title">
+          <span>查看权益</span>
+        </div>
+      </template>
+
+      <!-- 步骤条 -->
+      <el-steps :active="activeStep" simple style="margin-bottom: 20px;">
+        <el-step title="填写权益信息">
+          <template #icon><el-icon><InfoFilled /></el-icon></template>
+        </el-step>
+        <el-step title="设置规则">
+          <template #icon><el-icon><Setting /></el-icon></template>
+        </el-step>
+        <el-step title="更多设置">
+          <template #icon><el-icon><Monitor /></el-icon></template>
+        </el-step>
+      </el-steps>
+
+      <!-- 步骤内容 -->
+      <div v-show="activeStep === 0">
+        <!-- 基本信息 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">基本信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益来源">
+                  <el-radio-group v-model="form.source" disabled>
+                    <el-radio label="银行自有"></el-radio>
+                    <el-radio label="荣数商品"></el-radio>
+                    <el-radio label="第三方"></el-radio>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '荣数商品'">
+                <el-form-item label="选择权益">
+                  <el-input :value="form.rongshuProduct" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="权益类型">
+                  <el-radio-group v-model="form.productType" disabled>
+                    <el-radio label="券码"></el-radio>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '银行自有'">
+                <el-form-item label="商户名称">
+                  <el-input :value="form.merchantName" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label="外部权益链接">
+                  <el-select v-model="form.externalLink" placeholder="请选择外部权益链接" disabled>
+                    <el-option label="太古地产" value="太古地产"></el-option>
+                    <el-option label="龙腾" value="龙腾"></el-option>
+                    <el-option label="h5链接" value="h5链接"></el-option>
+                    <el-option label="小程序链接" value="小程序链接"></el-option>
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方' && form.externalLink === 'h5链接'" :gutter="20">
+                <el-form-item label="h5链接">
+                  <el-input v-model="form.customLink" placeholder="请输入h5链接" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8" v-if="form.source === '第三方'">
+                <el-form-item label-width="140px">
+                  <template #label>
+                    <span style="white-space: nowrap;">是否有详情页<el-tooltip content="没有详情页时直接从首页/列表页跳转" placement="top"><el-icon class="tip-icon"><QuestionFilled /></el-icon></el-tooltip></span>
+                  </template>
+                  <el-radio-group v-model="form.externalJumpMethod" disabled>
+                    <el-radio-button label="没有"></el-radio-button>
+                    <el-radio-button label="有"></el-radio-button>
+                  </el-radio-group>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20" v-if="form.source === '第三方' && form.externalLink === '小程序链接'">
+              <el-col :span="8">
+                <el-form-item label="小程序appid">
+                  <el-input v-model="form.miniAppId" readonly></el-input>
+                </el-form-item>
+              </el-col>
+              <el-col :span="8">
+                <el-form-item label="小程序页面地址" label-width="140px">
+                  <el-input v-model="form.miniAppPath" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20" v-if="form.source === '荣数商品'">
+                <el-col :span="8">
+                  <el-form-item label="库存数">
+                    <el-input :value="form.stockCount" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="权益名称">
+                    <el-input v-model="form.equityName" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+
+            <template v-else-if="form.source === '第三方'">
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="权益名称">
+                    <el-input v-model="form.equityName" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="8">
+                  <el-form-item label="权益副标题">
+                    <el-input v-model="form.subTitle" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20" v-if="form.externalJumpMethod === '没有'">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row v-if="form.externalJumpMethod === '有'" :gutter="20">
+                <el-col :span="8">
+                  <el-form-item>
+                    <template #label>
+                      <span>权益图标
+                        <el-tooltip content="展示在权益首页和权益列表页" placement="top">
+                          <el-icon class="tip-icon"><QuestionFilled /></el-icon>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                    <div v-if="form.listIcon && form.listIcon.length" class="image-preview">
+                      <img :src="form.listIcon[0]" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="SIP商品编号">
+                  <el-input v-model="form.skuNo" readonly></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </el-form>
+        </el-card>
+
+        <!-- 详情信息 -->
+        <el-card v-if="shouldShowDetailCard" shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">详情信息</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-row :gutter="20">
+              <el-col :span="8">
+                <el-form-item label="权益主图">
+                  <div v-if="form.mainImage && form.mainImage.length" class="image-preview">
+                    <img :src="form.mainImage[0]" class="preview-img" />
+                  </div>
+                  <span v-else class="no-data">暂无图片</span>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益介绍">
+                  <el-input
+                    v-model="form.introduction"
+                    type="textarea"
+                    :rows="4"
+                    readonly
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益有效期">
+                  <el-input
+                    :value="form.validityType === 'fromGrantPlan' ? '取自发放计划' : (form.validityText || '-')"
+                    readonly
+                  ></el-input>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="权益内容">
+                  <div class="rich-text-view" v-html="form.overview || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="权益说明">
+                  <div class="rich-text-view" v-html="form.details || '暂无内容'"></div>
+                </el-form-item>
+              </el-col>
+            </el-row>
+            <template v-if="form.source === '银行自有' || form.source === '荣数商品'">
+              <el-row :gutter="20">
+                <el-col :span="24">
+                  <el-form-item label="下单成功短信">
+                    <el-switch v-model="form.smsEnabled" disabled></el-switch>
+                    <template v-if="form.smsEnabled">
+                      <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                        <el-select
+                          v-model="form.smsTemplateId"
+                          placeholder="请选择短信模板"
+                          style="width: 100%"
+                          disabled
+                        >
+                          <el-option
+                            v-for="item in smsTemplateOptions"
+                            :key="item.id"
+                            :label="item.name"
+                            :value="item.id"
+                          />
+                        </el-select>
+                        <div v-if="form.smsTemplate" class="sms-template-preview">{{ form.smsTemplate }}</div>
+                      </div>
+                    </template>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+              <el-row :gutter="20">
+                <el-col :span="8">
+                  <el-form-item label="领取成功按钮">
+                    <el-input :value="form.successButtonText" readonly></el-input>
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </template>
+          </el-form>
+        </el-card>
+
+      </div>
+
+      <div v-show="activeStep === 1">
+        <!-- 设置规则 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">设置规则</div>
+          </template>
+
+          <!-- 展示规则 -->
+          <div class="rule-section">
+            <h4>展示规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="展示时间限制">
+                <el-switch v-model="form.showTimeEnabled" disabled></el-switch>
+                <template v-if="form.showTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="form.showStartTime" readonly style="width: 100%"></el-input>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-input :value="form.showEndTime" readonly style="width: 100%"></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="展示客群限制">
+                <el-switch v-model="form.showGroupEnabled" disabled></el-switch>
+                <template v-if="form.showGroupEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="(form.showGroupTagIds || []).join('、')" readonly></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+
+          <!-- 领取规则 -->
+          <div class="rule-section">
+            <h4>领取规则</h4>
+            <el-form :model="form" label-width="120px" label-position="left">
+              <el-form-item label="领取时间限制">
+                <el-switch v-model="form.claimTimeEnabled" disabled></el-switch>
+                <template v-if="form.claimTimeEnabled">
+                  <div style="display: flex;width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-input :value="form.claimStartTime" readonly style="width: 100%"></el-input>
+                    <div style="width: 50px;text-align: center;">-</div>
+                    <el-input :value="form.claimEndTime" readonly style="width: 100%"></el-input>
+                  </div>
+                </template>
+              </el-form-item>
+              <el-form-item label="领取客群限制">
+                <el-switch v-model="form.claimGroupEnabled" disabled></el-switch>
+                <template v-if="form.claimGroupEnabled">
+                  <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                    <el-alert
+                      title="需在【发放计划】中选择权益发放,未发放的权益移动端不展示"
+                      type="info"
+                      :closable="false"
+                      show-icon
+                      style="margin-bottom: 12px;"
+                    ></el-alert>
+                    <div style="font-size: 14px;color: #606266;margin-bottom: 8px;font-weight: 500;">不满足提示</div>
+                    <el-form-item label="提示内容" label-width="80px" style="margin-bottom: 12px;">
+                      <el-input :value="form.claimDenyMessage" readonly></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮文案" label-width="100px" style="margin-bottom: 12px;">
+                      <el-input :value="form.claimDenyButtonText" readonly></el-input>
+                    </el-form-item>
+                    <el-form-item label="跳转按钮链接" label-width="100px" style="margin-bottom: 0;">
+                      <el-input :value="form.claimDenyButtonLink" readonly></el-input>
+                    </el-form-item>
+                  </div>
+                </template>
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </div>
+
+      <div v-show="activeStep === 2">
+        <!-- 显示页面 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">显示页面</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="权益首页/列表页">
+              <el-radio-group v-model="form.showOnHome" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item label="组装页">
+              <el-radio-group v-model="form.showOnAssembly" disabled>
+                <el-radio label="显示">显示</el-radio>
+                <el-radio label="不显示">不显示</el-radio>
+              </el-radio-group>
+              <div
+                v-if="form.showOnAssembly === '显示'"
+                style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;"
+              >
+                <el-input :value="form.assemblyPages.join(',')" readonly></el-input>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 分享设置 -->
+        <el-card shadow="never" class="section-card">
+          <template #header>
+            <div class="section-title">分享设置</div>
+          </template>
+          <el-form :model="form" label-width="120px" label-position="left">
+            <el-form-item label="是否支持分享">
+              <el-switch v-model="form.shareEnabled" disabled></el-switch>
+              <template v-if="form.shareEnabled">
+                <div style="width: 600px;background: #f1f2f5;padding: 20px;margin-top: 10px;">
+                  <el-form-item label="分享名称" label-width="100px" style="margin-bottom: 16px;">
+                    <el-input :value="form.shareName" readonly></el-input>
+                  </el-form-item>
+                  <el-form-item label="分享图片" label-width="100px" style="margin-bottom: 0;">
+                    <div v-if="form.shareImage && form.shareImage.length" class="image-preview">
+                      <img :src="(form.shareImage[0] && form.shareImage[0].url) || ''" class="preview-img" />
+                    </div>
+                    <span v-else class="no-data">暂无图片</span>
+                  </el-form-item>
+                </div>
+              </template>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+
+      <!-- 操作日志 -->
+      <el-card shadow="never" class="section-card operation-log-card">
+        <template #header>
+          <div class="section-title">操作日志</div>
+        </template>
+        <el-table :data="operationLogs" border style="width: 100%" empty-text="暂无操作日志">
+          <el-table-column prop="operator" label="操作人" width="160"></el-table-column>
+          <el-table-column prop="operateTime" label="操作日期" width="200"></el-table-column>
+          <el-table-column prop="action" label="操作">
+            <template #default="scope">
+              <el-tag :type="actionTagType(scope.row.action)" size="small">{{ scope.row.action }}</el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-card>
+
+      <!-- 操作栏 -->
+      <div class="action-bar">
+        <div class="action-left">
+          <el-button
+            v-if="activeStep > 0"
+            @click="prevStep"
+          >
+            <el-icon><ArrowLeft /></el-icon>上一步
+          </el-button>
+        </div>
+        <div class="action-center">
+          <template v-if="activeStep === 2">
+            <el-button @click="goBack">返回</el-button>
+            <el-button
+              v-if="canRequestOffshelf"
+              type="danger"
+              plain
+              @click="handleOffshelf"
+            >下架</el-button>
+          </template>
+        </div>
+        <div class="action-right">
+          <el-button
+            v-if="activeStep < 2"
+            type="primary"
+            @click="nextStep"
+          >下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
+        </div>
+      </div>
+    </el-card>
+
+  </div>
+</template>
+
+<script>
+import { ElMessage, ElMessageBox } from 'element-plus'
+import request from '@/utils/request'
+
+export default {
+  name: 'ViewEquityProduct',
+  data() {
+    return {
+      activeStep: 0,
+      currentShelfStatus: '',
+      currentAuditStatus: '',
+      smsTemplateOptions: [
+        { id: 'SMS_TPL_001', name: '通用权益支付成功模板', content: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}' },
+        { id: 'SMS_TPL_002', name: '券码类短信模板', content: '【银行】尊敬的客户,您的${productName}购买成功,券码为${exchangeNum},请于${expireDate}前使用。' },
+        { id: 'SMS_TPL_003', name: '实物类短信模板', content: '【银行】您购买的${productName}已受理,我们将尽快安排发货,感谢您的支持。' }
+      ],
+      form: {
+        source: '银行自有',
+        rongshuProduct: '',
+        productType: '券码',
+        faceValueThreshold: null,
+        faceValueDiscount: null,
+        stockCount: null,
+        shareEnabled: false,
+        shareImage: [],
+        shareName: '',
+        externalJumpMethod: '没有',
+        externalLink: '太古地产',
+        customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+        skuNo: '',
+        equityName: '',
+        productId: '',
+        merchantName: '',
+        subTitle: '',
+        listIcon: [],
+        mainImage: [],
+        introduction: '',
+        validityType: 'fromGrantPlan',
+        validityText: '',
+        overview: '',
+        details: '',
+        smsEnabled: false,
+        smsTemplateId: '',
+        smsTemplate: '',
+        successButtonText: '',
+        showTimeEnabled: false,
+        showStartTime: '',
+        showEndTime: '',
+        showGroupEnabled: false,
+        showGroupTagIds: [],
+        claimTimeEnabled: false,
+        claimStartTime: '',
+        claimEndTime: '',
+        claimGroupEnabled: false,
+        claimGroupIsPotential: false,
+        claimGroupTagId: '',
+        claimDenyMessage: '',
+        claimDenyButtonText: '',
+        claimDenyButtonLink: '',
+        showOnHome: '显示',
+        showOnAssembly: '不显示',
+        assemblyPages: []
+      },
+      operationLogs: []
+    }
+  },
+  computed: {
+    shouldShowDetailCard() {
+      const s = this.form.source
+      if (s === '银行自有' || s === '荣数商品') return true
+      if (s === '第三方' && this.form.externalJumpMethod === '有') return true
+      return false
+    },
+    canRequestOffshelf() {
+      return this.currentShelfStatus === '已上架' && this.currentAuditStatus === '审核通过'
+    }
+  },
+  created() {
+    this.loadData()
+    this.loadStatus()
+  },
+  methods: {
+    loadData() {
+      const id = this.$route.params.id
+      // TODO: 根据 id 从接口获取数据,这里用 mock 数据示例
+      const mockMap = {
+        'Q100001': {
+          source: '银行自有',
+          externalJumpMethod: '',
+          externalLink: '',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU001',
+          equityName: '权益A',
+          subTitle: '副标题示例',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>权益内容内容</p>',
+          details: '<p>权益说明内容</p>',
+          smsEnabled: true,
+          smsTemplateId: 'SMS_TPL_001',
+          smsTemplate: '【银行】您购买的${productName}已支付成功,卡号:${exchangeNum},卡密:${exchangePwd},有效期至${expireDate},详情:${qrCodeDownPaths}',
+          successButtonText: '马上去使用',
+          showTimeEnabled: true,
+          showStartTime: '2026-01-01 00:00:00',
+          showEndTime: '2026-12-31 23:59:59',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-01-01 00:00:00',
+          claimEndTime: '2026-12-31 23:59:59',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100002': {
+          source: '第三方',
+          externalJumpMethod: '没有',
+          externalLink: '太古地产',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU002',
+          equityName: '权益B',
+          subTitle: '外部列表页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '',
+          details: '',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: true,
+          showGroupTagIds: ['客群A'],
+          claimTimeEnabled: true,
+          claimStartTime: '2026-02-01 00:00:00',
+          claimEndTime: '2026-11-30 23:59:59',
+          claimGroupEnabled: true,
+          claimGroupIsPotential: false,
+          claimGroupTagId: '客群奖励活动A',
+          claimDenyMessage: '您暂不满足领取条件',
+          claimDenyButtonText: '去升级',
+          claimDenyButtonLink: 'https://example.com/upgrade'
+        },
+        'Q100003': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: '龙腾',
+          customLink: '',
+        miniAppId: '',
+        miniAppPath: '',
+          skuNo: 'SKU003',
+          equityName: '权益C',
+          subTitle: '外部详情页副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>外部详情页权益内容</p>',
+          details: '<p>外部详情页权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        },
+        'Q100004': {
+          source: '第三方',
+          externalJumpMethod: '有',
+          externalLink: 'h5链接',
+          customLink: 'https://example.com/custom',
+          skuNo: 'SKU004',
+          equityName: '权益D',
+          subTitle: '外部自定义链接副标题',
+          listIcon: [],
+          mainImage: [],
+          overview: '<p>自定义链接权益内容</p>',
+          details: '<p>自定义链接权益说明</p>',
+          smsEnabled: false,
+          smsTemplateId: '',
+          smsTemplate: '',
+          successButtonText: '',
+          showTimeEnabled: false,
+          showStartTime: '',
+          showEndTime: '',
+          showGroupEnabled: false,
+          showGroupTagIds: [],
+          claimTimeEnabled: false,
+          claimStartTime: '',
+          claimEndTime: '',
+          claimGroupEnabled: false,
+          claimGroupTagId: '',
+          claimDenyMessage: '',
+          claimDenyButtonText: '',
+          claimDenyButtonLink: ''
+        }
+      }
+      if (id && mockMap[id]) {
+        Object.assign(this.form, mockMap[id])
+      } else if (id) {
+        Object.assign(this.form, mockMap['Q100001'])
+      }
+
+      const logsMap = {
+        'Q100001': [
+          { operator: '张三', operateTime: '2026-01-10 09:32:15', action: '创建' },
+          { operator: '李四', operateTime: '2026-01-10 14:08:42', action: '审核' },
+          { operator: '赵六', operateTime: '2026-03-01 16:45:30', action: '下架' }
+        ],
+        'Q100002': [
+          { operator: '张三', operateTime: '2026-01-12 10:15:00', action: '创建' },
+          { operator: '李四', operateTime: '2026-01-12 15:22:18', action: '审核' }
+        ],
+        'Q100003': [
+          { operator: '钱七', operateTime: '2026-01-15 11:03:27', action: '创建' },
+          { operator: '李四', operateTime: '2026-01-15 17:30:09', action: '审核' }
+        ],
+        'Q100004': [
+          { operator: '钱七', operateTime: '2026-02-01 09:48:12', action: '创建' },
+          { operator: '李四', operateTime: '2026-02-01 14:11:36', action: '审核' },
+          { operator: '赵六', operateTime: '2026-04-05 18:22:47', action: '下架' }
+        ]
+      }
+      this.operationLogs = (id && logsMap[id]) ? logsMap[id] : (id ? logsMap['Q100001'] : [])
+    },
+    actionTagType(action) {
+      const map = {
+        '创建': 'success',
+        '审核': 'primary',
+        '下架': 'danger'
+      }
+      return map[action] || 'info'
+    },
+    loadStatus() {
+      const id = this.$route.params.id
+      if (!id) return
+      request.get(`/api/equity/detail?id=${id}`).then(res => {
+        if (res.data) {
+          this.currentShelfStatus = res.data.shelfStatus || ''
+          this.currentAuditStatus = res.data.auditStatus || ''
+          if (res.data.equityName) this.form.equityName = res.data.equityName
+        }
+      }).catch(() => {})
+    },
+    handleOffshelf() {
+      ElMessageBox.confirm(`确定提交"${this.form.equityName}"的下架审核吗?审核通过后才会下架。`, '提示', {
+        confirmButtonText: '提交',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        request.post('/api/equity/request-offshelf', {
+          productId: this.$route.params.id
+        }).then(() => {
+          ElMessage.success('已提交下架审核')
+          this.$router.push('/customization/hsbc/equity-products')
+        }).catch(() => {})
+      }).catch(() => {})
+    },
+    nextStep() {
+      if (this.activeStep < 2) {
+        this.activeStep++
+      }
+    },
+    prevStep() {
+      if (this.activeStep > 0) {
+        this.activeStep--
+      }
+    },
+    goBack() {
+      this.$router.push('/customization/hsbc/equity-products')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.view-equity-product {
+  padding: 20px;
+}
+.tip-icon {
+  color: #909399;
+  cursor: pointer;
+  font-size: 14px;
+}
+.header-title {
+  font-size: 18px;
+  font-weight: 500;
+}
+.section-card {
+  margin-bottom: 16px;
+  border: none;
+}
+.operation-log-card {
+  margin-top: 8px;
+}
+.section-card >>> .el-card__header {
+  padding: 12px 20px;
+  background: #fafafa;
+  border-bottom: 1px solid #ebeef5;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+.rule-section {
+  margin-bottom: 24px;
+}
+.rule-section h4 {
+  font-size: 14px;
+  font-weight: 600;
+  color: #606266;
+  margin-bottom: 16px;
+  border-bottom: 1px solid #ebeef5;
+  padding-bottom: 8px;
+}
+.action-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid #ebeef5;
+}
+.action-left, .action-right {
+  width: 120px;
+}
+.action-center {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+}
+.image-preview {
+  width: 80px;
+  height: 80px;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.preview-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+.no-data {
+  color: #c0c4cc;
+  font-size: 13px;
+}
+.rich-text-view {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  padding: 8px 12px;
+  min-height: 80px;
+  font-size: 14px;
+  color: #606266;
+  line-height: 1.6;
+}
+.sms-template-preview {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #606266;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 21 - 0
pc-v3/vite.config.js

@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  base: './',
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  server: {
+    port: 8080,
+    host: true,
+    watch: {
+      usePolling: true,
+      interval: 1000
+    }
+  }
+})

+ 9 - 283
prd/汇丰银行/汇丰借记卡权益.md → prd/汇丰银行/汇丰借记卡权益移动端.md

@@ -1,271 +1,5 @@
-# 一、需求背景
+**与后台的关联说明:**
 
-汇丰当前只有信用卡权益,希望支持全行权益,提升用户活跃度。
-
-# 二、需求列表
-
-## (一)后台
-
-入口:定制功能—>汇丰银行
-
-**权益商品管理**
-
-1.  权益添加(两步骤向导:填写商品信息→设置规则)
-    
-2.  权益编辑
-    
-3.  权益查看
-    
-4.  权益列表
-    
-5.  审核
-    
-6.  下架
-    
-
-## (二)移动端
-
-1.  权益主页
-    
-2.  权益详情
-    
-3.  权益订单详情
-    
-4.  我的立减金
-    
-
-# 三、需求详述
-
-# (一)后台
-
-菜单入口:liso 定制功能-汇丰银行
-
-![|51%](../../.resources/image-1776675434545.png)
-
-## 1\. 添加权益商品
-
-![](../../.resources/image-1776677751099.png)
-
-添加权益商品采用两步骤向导:**填写商品信息 → 设置规则**。
-
-权益商品按**商品来源**分为两类:**银行自有**和**第三方**。
-
--   商品来源为"银行自有"时,填写商品信息步骤包含价格信息区域;
-    
--   商品来源为"第三方"时,不展示价格信息。
-    
-
-### 步骤一:填写商品信息
-
-### **1.1 公共字段**
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 商品来源 | 单选按钮组 | 是   | 银行自有 / 第三方,默认选择"银行自有"。  <br>  <br>选择后表单字段根据来源类型动态展示。 |
-| 商户名称 | 下拉选择框 | 是   | 仅当商品来源为"银行自有"时显示,与商品来源同行展示。支持搜索筛选。 |
-
-### **1.2 银行自有**
-
-![](../../.resources/image-1776677676974.png)
-
-当商品来源为"银行自有"时,展示以下字段:
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| SKU编号 | 下拉选择框 | 是   | 选择关联的商品SKU。  <br>1\. 输入sku编号  <br>2\. 查询商城机构定价包含汇丰银行的SIP渠道银行自有商品,按照"商品名称-SKU编号"在列表回显,如果无结果显示"无相关商品"。输入框支持清空。  <br>3\. 选择商品自动填充:商品名称→权益名称、商品图片→权益列表图标、商户价格信息 |
-| 权益名称 | 文本输入框 | 是   | 最长可输入40个字符 |
-| 权益副标题 | 文本输入框 | 否   | 权益的副标题说明,最长可输入40个字符 |
-| 权益列表图标 | 图片上传 | 是   | 上传列表权益图标图片,只能传一张 |
-| 权益主图 | 图片上传 | 是   | 上传权益主图图片,只能传一张 |
-| 权益概述 | 富文本输入框 | 是   | 权益的详细内容说明,最长500个字符 |
-| 权益详情 | 富文本输入框 | 是   | 权益的使用说明,最长1000个字符 |
-| 下单成功短信 | 开关 + 下拉选择框 | 否   | 该字段单独占一行。默认关闭,开启后在开关下方显示"短信模板"下拉选择框,选择模板后回显模板内容(只读预览)。关闭则不发送短信。  <br>字段需显示tips:此模板为权益支付成功,发送给持卡人的短信模板;开启后需选择短信模板,关闭则不发送短信。 占位符如下 : 商品名称:${productName} ; 结束日期:${expireDate} ; 卡号: ${exchangeNum} ;卡密 :${exchangePwd};券码路径:${qrCodeDownPaths}。  <br>**短信模板**下拉选项来源:已配置的短信模板列表(模板 ID + 模板名称),保存时存储选中的模板 ID。 |
-| 领取成功按钮文案 | 文本输入框 | 是   | 领取成功后按钮显示的文案,默认值:"马上去使用" |
-
-**价格信息**
-
-> 当为银行自有且选择了sku编号时显示
-
-![](../../.resources/image-1776678323140.png)
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 科目号 | 选择框 | 是   | 可输入的选择框。  <br>  <br>选择框中展示科目号。 |
-
-### **1.3 外部**
-
-当商品来源为"第三方"时,先选择外部商品链接和跳转触发页,再根据跳转触发页展示不同字段:
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 外部商品链接 | 下拉选择框 | 是   | 可选值:太古地产、龙腾、自定义。  <br>选择"自定义"时,显示自定义链接输入框(必填,最长400个字符)。 |
-| 跳转触发页 | 下拉选择框 | 是   | 可选值:权益列表页、权益详情页。  <br>根据选择展示不同的表单字段。 |
-
-**(1)跳转触发页 = 权益列表页** 时,展示以下字段:
-
-![](../../.resources/image-1776677854190.png)
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 权益名称 | 文本输入框 | 是   | 最长可输入40个字符 |
-| 权益副标题 | 文本输入框 | 否   | 权益的副标题说明,最长可输入40个字符 |
-| 权益列表图标 | 图片上传 | 是   | 上传权益图标图片,只能传一张 |
-
-**(2)跳转触发页 = 权益详情页** 时,展示以下字段:
-
-![](../../.resources/image-1776677883654.png)
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 权益名称 | 文本输入框 | 是   | 最长可输入40个字符 |
-| 权益副标题 | 文本输入框 | 否   | 权益的副标题说明,最长可输入40个字符 |
-| 权益列表图标 | 图片上传 | 是   | 上传权益图标图片,只能传一张 |
-| 权益主图 | 图片上传 | 是   | 上传权益主图图片,只能传一张 |
-| 权益概述 | 富文本输入框 | 是   | 权益的详细内容说明,最长500个字符 |
-| 权益详情 | 富文本输入框 | 是   | 权益的使用说明,最长1000个字符 |
-
-### 步骤二:设置规则
-
-![](../../.resources/image-1776678543717.png)
-
-**展示规则**
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 展示时间限制 | 开关(Toggle) | 否   | 默认关闭。开启后可配置展示的开始时间和结束时间,格式 Y-m-d H:i:s |
-| 展示客群限制 | 开关(Toggle) | 否   | 默认关闭。开启后可选择客群标签ID(单选) |
-
-**领取规则**
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 领取时间限制 | 开关(Toggle) | 否   | 默认关闭。开启后可配置领取的开始时间和结束时间,格式 Y-m-d H:i:s |
-| 领取客群限制 | 开关(Toggle) | 否   | 默认关闭。  <br>开启后可配置  <br>• 标签:标签ID,必填,最长可输入50个字符  <br>• 不满足提示  <br>◦ 提示内容:必填,最长可输入200个字符  <br>◦ 跳转按钮文案:必填,最长可输入10个字符  <br>◦ 跳转按钮链接:必填,最长可输入200个字符 |
-
-### 操作按钮
-
--   **上一步/下一步:** 在步骤之间导航
-    
--   **取消:** 放弃当前操作,返回权益列表页
-    
--   **提交:** 在最后一步显示,校验必填项,通过后提交创建权益商品记录,状态变为【待审核】
-    
-
-## 2\. 权益编辑
-
-仅待审核、审核拒绝和已下架的权益商品可编辑。
-
-与添加权益商品相比,除了【商品来源】不可编辑,其他均可编辑。
-
--   **取消:** 放弃当前操作,返回权益列表页。
-    
--   **保存:** 校验必填项,通过后更新权益商品记录,状态变为【待审核】。
-    
-
-<br>
-
-<br>
-
-<br>
-
-<br>
-
-<br>
-
-<br>
-
-<br>
-
-<br>
-
-![](../../.resources/image-1776678460565.png)![](../../.resources/image-1776678477468.png)
-
-## 3\. 权益查看
-
-字段同权益编辑
-
--   输入框和选择框禁用
-    
--   不展示取消和提交按钮。
-    
-
-![](../../.resources/image-1776678401993.png)
-
-## 4\. 权益列表
-
-![](../../.resources/image-1776678565000.png)
-
-### (1)筛选条件
-
-| **字段名称** | **字段类型** | **是否必填** | **说明** |
-| --- | --- | --- | --- |
-| 权益名称 | 文本输入框 | 否   | 模糊搜索权益名称 |
-| 商品ID | 文本输入框 | 否   | 精确匹配商品ID |
-| SKU编号 | 文本输入框 | 否   | 精确匹配SKU编号 |
-| 科目号 | 文本输入框 | 否   | 精确匹配科目号 |
-
-### (2)操作按钮
-
--   查询:根据筛选条件查询权益列表
-    
--   重置:清空所有筛选条件
-    
--   添加权益商品:打开【权益添加】页面
-    
-
-### (3)列表字段
-
-| 字段名称 | **说明** |
-| --- | --- |
-| 操作  | • 查看:查看权益详情  <br>• 编辑:编辑权益信息  <br>• 审核:审核状态为待审核时显示  <br>• 下架:审核状态为审核通过或已上架时显示,点击下架该权益 |
-| 权益名称 | 权益的名称 |
-| 审核状态 | 待审核、审核通过、审核拒绝、已上架、已下架 |
-| 商品来源 | 银行自有 / 第三方 |
-| 外部商品链接 | 外部商品链接类型(太古地产/龙腾/自定义),银行自有时显示"-" |
-| 跳转触发页 | 权益列表页 / 权益详情页,银行自有时显示"-" |
-| 商品ID | 商品ID |
-| SKU编号 | 关联的商品SKU编号 |
-| 商户名称 | 商品商户名称 |
-| 审核人 | 执行审核的操作人 |
-| 审核时间 | 审核操作的时间 |
-
-### (4)分页
-
--   默认每页显示 20 条记录
-    
--   支持翻页:首页、上一页、下一页、末页、跳转至指定页
-    
-
-## 5\. 权益审核
-
-当权益商品状态为【待审核】时,在列表展示【审核】操作按钮。
-
-展示的字段同【查看权益】,底下增加操作
-
--   **审核拒绝:**
-    
-    -   点击后弹窗二次确认,确认后该权益商品状态变为【审核拒绝】。
-        
--   **审核通过:**
-    
-    -   点击后弹窗二次确认,确认后该权益商品状态变为【已上架】。
-        
-
-![](../../.resources/image-1776678587748.png)
-
-## 6\. 权益下架
-
-当权益商品为【审核通过】或【已上架】时在列表展示按钮,点击后弹窗提示【确定要下架权益"XXX"吗?】
-
--   确定:将该权益商品状态变为【已下架】,前台不再展示且无法兑换下单
-    
--   取消:关闭弹窗
-    
-
-# (二)移动端
-
-> **与后台的关联说明:**
-> 
 > -   移动端根据后台"商品来源"和"跳转触发页"两个字段组合判断跳转逻辑
 >     
 > -   后台"外部商品链接"字段(太古地产/龙腾/自定义)决定外部跳转时的交互流程
@@ -342,14 +76,6 @@
                 
                 <br>
                 
-                <br>
-                
-                <br>
-                
-                <br>
-                
-                <br>
-                
                 ![](../../.resources/image-1776678728870.png)![](../../.resources/image-1776678755981.png)
         -   **龙腾:** 点击后弹出授权提醒
             
@@ -453,6 +179,14 @@
         
         <br>
         
+        <br>
+        
+        <br>
+        
+        <br>
+        
+        <br>
+        
         ![](../../.resources/image-1776678923546.png)![](../../.resources/image-1776678942001.png)
 
 **(2)领取权益**
@@ -485,14 +219,6 @@
 -   **按钮跳转:** 跳转权益订单详情页
     
 
-<br>
-
-<br>
-
-<br>
-
-<br>
-
 <br>
 
 <br>

+ 136 - 113
prd/汇丰银行/汇丰银行权益中台-FSD.md

@@ -2,8 +2,6 @@
 
 # 一、系统说明
 
-<div class="joplin-table-wrapper"><table style="min-width: 50px;"><thead><tr><th colspan="1" rowspan="1"><p data-id="09297a1d-76d4-47fe-bbf6-8ed55cd246b5">系统名称</p></th><th colspan="1" rowspan="1"><p data-id="dc0f8cce-ffac-48d3-ab58-17c9090bfb2b">系统说明</p></th></tr></thead><tbody><tr><td colspan="1" rowspan="1"><p data-id="2f6d5150-0b59-4c26-8657-a8988ae42a06">新商城(Liso)</p></td><td colspan="1" rowspan="1"><ul data-id="0dd899c2-b580-4ded-90a7-2f52f714a40d"><li><p data-id="dea844c8-c7f6-4140-affe-50ba80ea4010">权益中台:进行页面管理、权益管理、客群管理、发放计划、短信管理、报表查看</p></li><li><p data-id="c8e560be-e717-4a0d-8b32-d245988531ec">系统设置:进行角色管理,创建不同操作权限的角色</p></li></ul></td></tr><tr><td colspan="1" rowspan="1"><p data-id="201e0ac0-8eda-4d83-87eb-a5f9c7227e5d">SIP</p></td><td colspan="1" rowspan="1"><p data-id="02e54e41-839b-407a-a27b-63a266f3025f">券码的导入、查看和回收</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="968c0b79-e03e-44c3-affa-3aa3bd71be5b">统一门户</p></td><td colspan="1" rowspan="1"><ul data-id="fe7ba701-4d06-432c-bef5-d341b5e296d9"><li><p data-id="3f56a8d3-b895-4c33-b231-63a275d8d168">统一单点登录入口</p></li><li><p data-id="22cc52cc-c0c2-49d5-a1b2-02e297f05e3e">统一管理各个系统(Liso、SIP)的用户,并在这里给用户分配对应系统的角色</p></li></ul></td></tr></tbody></table></div>
-
 # 二、角色和用户管理
 
 ## 流程
@@ -19,17 +17,17 @@
 
 入口:【统一门户】->【新商城外跳联登】
 
-![](../../.resources/image-1779242912664.png)
+![BlockNote image](../../.resources/image-1779242912664.png)
 
 ### 1.1 角色列表
 
 展示全部已创建的【普通角色】类型的角色。
 
-![](../../.resources/image-1779242923866.png)
+![BlockNote image](../../.resources/image-1779242923866.png)
 
 ### 1.2 添加/编辑角色
 
-![](../../.resources/image-1779242933886.png)
+![BlockNote image](../../.resources/image-1779242933886.png)
 
 点击【添加】可添加角色。
 
@@ -42,19 +40,19 @@
 -   **按钮权限:**选了最后一级菜单后,会展示对应页面的【按钮权限】,每个菜单的【按钮权限】都会在上线前配置完成。
     
 
-![](../../.resources/image-1779242945672.png)
+![BlockNote image](../../.resources/image-1779242945672.png)
 
 ## 2\. 用户管理
 
 ### 2.1 用户列表
 
-![](../../.resources/image-1779242966310.png)
+![BlockNote image](../../.resources/image-1779242966310.png)
 
 ### 2.2 添加用户
 
 > **一期不需要添加用户,我们会批量导入用户进去。**
 
-![](../../.resources/image-1779242979631.png)
+![BlockNote image](../../.resources/image-1779242979631.png)
 
 点击【添加】可添加用户。
 
@@ -73,24 +71,28 @@
 -   手机号:必填,用于登录发送验证码校验添加完成后,会给该用户的手机号发送包含 账号名称和初始密码 的短信。
     
 
-![](../../.resources/image-1779242994166.png)
+![BlockNote image](../../.resources/image-1779242994166.png)
 
 ### 2.3 分配角色
 
-![](../../.resources/image-1779243010146.png)![](../../.resources/image-1779243019476.png)
+<br>
+
+<br>
+
+![BlockNote image](../../.resources/image-1779243010146.png)![BlockNote image](../../.resources/image-1779243019476.png)
 
 ## 3\. 用户登录
 
 登录页填写账号、密码即可登录。
 
-![](../../.resources/image-1779243033492.png)
+![BlockNote image](../../.resources/image-1779243033492.png)
 
 ## 4\. 用户配置计划
 
 -   一期:在CUPD中独立配置,我们会批量导入行内账号,可通过账号和密码登录。
     
 
-![](../../.resources/image-1779243049241.png)
+![BlockNote image](../../.resources/image-1779243049241.png)
 
 -   二期:接行内AD,然后和行内的用户匹配关联。
     
@@ -147,7 +149,7 @@
 
 ### 2.1 权益列表
 
-![](screenshots/2-1-equity-products-list.png)
+![BlockNote image](./screenshots/2-1-equity-products-list.png)
 
 **2.1.1 筛选条件**
 
@@ -171,15 +173,15 @@
 
 | 字段  | 说明  |
 | --- | --- |
-| 操作  | 行内主操作按钮 +「更多」下拉菜单<br><br>按"商品状态 + 审核状态"显隐对应操作,详见 2.1.3 操作可见性矩阵 |
+| 操作  | 行内主操作按钮 +「更多」下拉菜单按"商品状态 + 审核状态"显隐对应操作,详见 2.1.3 操作可见性矩阵 |
 | 权益名称 | 权益名称,超过一行时换行显示 |
-| 商品状态 | 使用色块 Tag 展示  <br>可选值:未上架(灰)/ 已上架(绿) |
-| 审核状态 | 使用色块 Tag 展示  <br>可选值:草稿(灰)/ 上架待审核(默认)/ 下架待审核(红)/ 审核通过(绿) |
+| 商品状态 | 使用色块 Tag 展示 可选值:未上架(灰)/ 已上架(绿) |
+| 审核状态 | 使用色块 Tag 展示 可选值:草稿(灰)/ 上架待审核(默认)/ 下架待审核(红)/ 审核通过(绿) |
 | 权益来源 | 银行自有 / 荣数商品 / 第三方 |
 | 外部权益链接 | 第三方权益的链接来源:太古地产 / 龙腾 / h5链接 / 小程序链接;非第三方显示"-" |
-| 是否有详情页 | 仅第三方权益使用,标识跳转触发位:<br><br>"有" = 用户进入权益详情页后再跳出;<br><br>"没有" = 用户在首页/列表页/组装页“点击后直接跳出;<br><br>非第三方显示"-" |
+| 是否有详情页 | 仅第三方权益使用,标识跳转触发位:"有" = 用户进入权益详情页后再跳出;"没有" = 用户在首页/列表页/组装页“点击后直接跳出;非第三方显示"-" |
 | 权益编号 | 系统生成,统一 `Q` + 6 位序号格式(如 `Q000001`),全局唯一 |
-| 显示页面 | 使用 Tag 展示当前权益启用的展示位:开启"权益首页/列表页"时显示一个「权益首页/列表页」Tag;<br><br>开启"组装页"时按所选组装页(组装页 A/B/C/D…)追加 Tag。<br><br>两项均未开启时显示"-" |
+| 显示页面 | 使用 Tag 展示当前权益启用的展示位:开启"权益首页/列表页"时显示一个「权益首页/列表页」Tag;开启"组装页"时按所选组装页(组装页 A/B/C/D…)追加 Tag。两项均未开启时显示"-" |
 | 创建人 | 创建该权益的用户昵称 |
 | 创建时间 | Y-m-d H:i:s |
 | 审核人 | 最近一次审核操作的执行人昵称;未审核时为“-" |
@@ -203,7 +205,7 @@
 
 生成借记卡权益链接对话框:
 
-![](screenshots/dialog-equity-link.png)
+![BlockNote image](./screenshots/dialog-equity-link.png)
 
 2.1.4 分页
 
@@ -218,7 +220,7 @@
 
 ### 2.2 添加权益
 
-![](screenshots/2-2-equity-add-step1.png)
+![BlockNote image](./screenshots/2-2-equity-add-step1.png)
 
 权益添加采用**三步骤向导**:填写权益信息 → 设置规则 → 更多设置。
 
@@ -254,19 +256,23 @@
 | 权益副标题 | 否   | 最长 40 字符 |
 | 权益图标 | 是   | 上传 1 张,建议尺寸 80×80px。Tooltip:"展示在权益首页和权益列表页" |
 
-![](screenshots/2-2-equity-add-step1.png)
+![BlockNote image](./screenshots/2-2-equity-add-step1.png)
 
 **B. 权益来源 = 荣数商品**
 
 | 字段  | 必填  | 说明  |
 | --- | --- | --- |
-| 选择权益 | 是   | 只读输入框 + 【选择】按钮。弹窗"选择荣数商品"中按商品名称模糊搜索,每页 10 条;选中后自动回填权益名称、商户名称、库存数、权益主图。<br><br>**荣数商品池均为微信立减金商品**(如"微信立减金 10元(满50减10)"等) |
+| 选择权益 | 是   | 只读输入框 + 【选择】按钮。弹窗"选择荣数商品"中按商品名称模糊搜索,每页 10 条;选中后自动回填权益名称、商户名称、库存数、权益主图。**荣数商品池均为微信立减金商品**(如"微信立减金 10元(满50减10)"等) |
 | 权益名称 | 是   | 最长 40 字符,由"选择权益"自动回填,仍可手动编辑 |
 | 权益副标题 | 否   | 最长 40 字符 |
 | 库存数 | 是   | 整数,范围 0 ~ 100000,由"选择权益"自动回填,最大不超过荣数的库存 |
 | 权益图标 | 是   | 上传 1 张,建议尺寸 80×80px |
 
-![](screenshots/2-2-equity-add-source-rongshu.png)![](screenshots/dialog-rongshu-product.png)
+<br>
+
+<br>
+
+![BlockNote image](./screenshots/2-2-equity-add-source-rongshu.png)![BlockNote image](./screenshots/dialog-rongshu-product.png)
 
 **C. 权益来源 = 第三方**
 
@@ -287,7 +293,7 @@
 
 注:第三方权益没有"权益类型"与"库存数"字段。
 
-![](screenshots/2-2-equity-add-third-detail.png)
+![BlockNote image](./screenshots/2-2-equity-add-third-detail.png)
 
 * * *
 
@@ -301,15 +307,15 @@
 | --- | --- | --- |
 | 权益主图 | 是   | 上传 1 张,建议尺寸 716×400px |
 | 权益介绍 | 是   | 多行文本,最长 500 字符,带字数实时提示 |
-| 权益有效期 | 是   | • 权益来源 = 银行自有 / 荣数商品:圆点单选「取自发放计划 / 自定义输入」,选"自定义输入"时下方出现文本框(最长 50 字符,带字数提示)  <br>• 权益来源 = 第三方:直接输入文本框(最长 50 字符) |
+| 权益有效期 | 是   | • 权益来源 = 银行自有 / 荣数商品:圆点单选「取自发放计划 / 自定义输入」,选"自定义输入"时下方出现文本框(最长 50 字符,带字数提示) • 权益来源 = 第三方:直接输入文本框(最长 50 字符) |
 | 权益内容 | 是   | 富文本编辑器,需支持PDF上传,可自定义PDF链接文本 |
 | 权益说明 | 是   | 富文本编辑器,需支持PDF上传,可自定义链接文本 |
-| 下单成功短信 | 否   | **仅银行自有 / 荣数商品显示。** 开关 + 短信模板下拉。<br><br>开启后选择已配置的短信模板,模板内容会在下方实时回显。模板支持占位符:`${productName}` 权益名称、`${expireDate}` 结束日期、`${exchangeNum}` 卡号、`${exchangePwd}` 卡密、`${qrCodeDownPaths}` 券码路径 |
+| 下单成功短信 | 否   | **仅银行自有 / 荣数商品显示。** 开关 + 短信模板下拉。开启后选择已配置的短信模板,模板内容会在下方实时回显。模板支持占位符:`${productName}` 权益名称、`${expireDate}` 结束日期、`${exchangeNum}` 卡号、`${exchangePwd}` 卡密、`${qrCodeDownPaths}` 券码路径 |
 | 领取成功按钮 | 是   | 默认值"马上去使用",可修改 |
 
 2.2.2 第二步:设置规则
 
-![](../../.resources/image-1779247485788.png)
+![BlockNote image](../../.resources/image-1779247485788.png)
 
 第二步包含**展示规则**与**领取规则**两组开关,所有开关默认关闭,关闭时不做任何限制。
 
@@ -325,7 +331,7 @@
 | 字段  | 类型  | 说明  |
 | --- | --- | --- |
 | 领取时间限制 | 开关  | 开启后配置开始/结束时间(精确到秒)。 |
-| 领取客群限制 | 开关  | 开启后展开顶部提示卡:"**需在【发放计划】中选择权益发放,未发放的权益移动端不展示**"。  <br>  <br>下方为**不满足提示**配置:  <br>• 提示内容(最长 200 字符)  <br>• 跳转按钮文案(最长 10 字符)  <br>• 跳转按钮链接(最长 200 字符) |
+| 领取客群限制 | 开关  | 开启后展开顶部提示卡:"**需在【发放计划】中选择权益发放,未发放的权益移动端不展示**"。 下方为**不满足提示**配置: • 提示内容(最长 200 字符) • 跳转按钮文案(最长 10 字符) • 跳转按钮链接(最长 200 字符) |
 
 注:**领取客群不再在权益处直接选定**,统一由【发放计划】维护"哪些客群可领"。本开关仅用于配置"不满足时的提示与跳转链接"。
 
@@ -337,7 +343,7 @@
 
 (1)显示页面(控制权益在移动端哪些页面展示)
 
-![](screenshots/2-2-equity-add-step3.png)
+![BlockNote image](./screenshots/2-2-equity-add-step3.png)
 
 | 字段  | 类型  | 说明  |
 | --- | --- | --- |
@@ -367,14 +373,14 @@
 | 上一步 | 当前步骤 > 1 | 回到上一个步骤 |
 | 下一步 | 当前步骤 < 3 | 进入下一个步骤 |
 | 取消  | 始终显示 | 直接返回权益列表,不做提示 |
-| 保存草稿 | 始终显示 | 校验"权益名称"非空后,落地一条记录:商品状态 = `未上架`、审核状态 = `草稿`。<br><br>后续可在列表点击「编辑」继续完善 |
-| 提交审核 | 当前步骤 = 3(仅第三步显示) | 校验必填后,创建记录:商品状态 = `未上架`、审核状态 = `上架待审核`。<br><br>审核通过后切换为"已上架 + 审核通过",新创建的商品默认排在列表第一位 |
+| 保存草稿 | 始终显示 | 校验"权益名称"非空后,落地一条记录:商品状态 = `未上架`、审核状态 = `草稿`。后续可在列表点击「编辑」继续完善 |
+| 提交审核 | 当前步骤 = 3(仅第三步显示) | 校验必填后,创建记录:商品状态 = `未上架`、审核状态 = `上架待审核`。审核通过后切换为"已上架 + 审核通过",新创建的商品默认排在列表第一位 |
 
 * * *
 
 ### 2.3 编辑权益
 
-![](screenshots/2-3-equity-edit.png)
+![BlockNote image](./screenshots/2-3-equity-edit.png)
 
 **入口与可见性**:权益列表行【操作】→【编辑】。【编辑】按钮仅在 **商品状态 =** `未上架` 时显示,其他状态下该按钮不出现。
 
@@ -422,15 +428,13 @@
 | 返回  | 当前步骤 = 3 | 返回权益列表 |
 | 下架  | 当前步骤 = 3 且 商品状态 = `已上架` 且 审核状态 = `审核通过` | 二次确认后提交下架审核(交互同 §2.6) |
 
-<br>
-
-![](screenshots/2-4-equity-view.png)
+![BlockNote image](./screenshots/2-4-equity-view.png)
 
 * * *
 
 ### 2.5 下架权益
 
-![](screenshots/dialog-offshelf-confirm.png)
+![BlockNote image](./screenshots/dialog-offshelf-confirm.png)
 
 **入口与可见性**:商品状态 = `已上架` 且 审核状态 = `审核通过` 时显示【下架】按钮
 
@@ -445,14 +449,14 @@
 
 | 按钮  | 行为  |
 | --- | --- |
-| 提交  | 调用下架接口,生成一条**下架审核单**:审核状态切到 `下架待审核`,商品状态保持 `已上架`(线上仍可见);提示"已提交下架审核"。<br><br>审核通过后切换为"未上架 + 审核通过",前台不再展示且无法兑换下单 |
+| 提交  | 调用下架接口,生成一条**下架审核单**:审核状态切到 `下架待审核`,商品状态保持 `已上架`(线上仍可见);提示"已提交下架审核"。审核通过后切换为"未上架 + 审核通过",前台不再展示且无法兑换下单 |
 | 取消  | 关闭弹窗,状态不变 |
 
 * * *
 
 ### 2.6 调整显示顺序
 
-![](screenshots/popover-adjust-order.png)
+![BlockNote image](./screenshots/popover-adjust-order.png)
 
 **入口与可见性**:列表行【操作】→【更多】→【调整显示顺序】。所有状态均可操作。
 
@@ -473,9 +477,10 @@
 
 ### 2.7 删除
 
-![](screenshots/dialog-delete-confirm.png)
+![BlockNote image](./screenshots/dialog-delete-confirm.png)
+
+**入口与可见性**:列表行【操作】→【更多】→【删除】。
 
-**入口与可见性**:列表行【操作】→【更多】→【删除】。  
 【删除】仅在 **商品状态 =** `未上架` **且 审核状态 ∈ {** `草稿`**,** `审核通过`**(即历史下架) }** 时显示,其他状态下不出现。
 
 **交互**:弹出二次确认弹窗 `确定删除权益"XXX"吗?`(类型:warning),按钮:
@@ -498,7 +503,7 @@
 
 ## 3\. 信用卡权益
 
-![](screenshots/3-credit-card-equity-list.png)
+![BlockNote image](./screenshots/3-credit-card-equity-list.png)
 
 信用卡权益由营销系统统一维护,仅维护「显示页面」「显示顺序」以及「生成链接」三项轻量操作。
 
@@ -525,16 +530,16 @@
 | --- | --- |
 | 操作  | 行内"调整显示顺序"、"设置显示页面"两个文字按钮,最右侧为【更多】下拉菜单;详见 3.4 操作 |
 | 权益名称 | 权益名称,来源于营销系统,超过一行换行显示 |
-| 显示页面 | 使用 Tag 展示当前权益启用的展示位:开启"权益首页/列表页"时显示一枚「权益首页/列表页」Tag;<br><br>开启"组装页"时按所选组装页(组装页 A/B/C/D…)追加 Tag。<br><br>两项均未开启时显示"-" |
+| 显示页面 | 使用 Tag 展示当前权益启用的展示位:开启"权益首页/列表页"时显示一枚「权益首页/列表页」Tag;开启"组装页"时按所选组装页(组装页 A/B/C/D…)追加 Tag。两项均未开启时显示"-" |
 
 ### 3.4 操作
 
-![](screenshots/dialog-show-pages.png)
+![BlockNote image](./screenshots/dialog-show-pages.png)
 
 | 操作  | 显示条件 | 行为  |
 | --- | --- | --- |
 | 调整显示顺序 | 任意状态 | 行内文字按钮。点击后弹气泡,交互、提示文案、校验范围与按钮均与 §2.7 一致 |
-| 设置显示页面 | 任意状态 | 行内文字按钮。点击后弹出"设置显示页面"对话框,与添加权益第三步「显示页面」字段一致:  <br>• **权益首页/列表页**:圆点单选 显示 / 不显示  <br>• **组装页**:圆点单选 显示 / 不显示,选"显示"时下方多选组装页 A/B/C/D  <br>底部按钮【取消】/【确定】。点击【确定】后立即生效,列表"显示页面"列以 Tag 形式回显,并提示"显示页面已更新" |
+| 设置显示页面 | 任意状态 | 行内文字按钮。点击后弹出"设置显示页面"对话框,与添加权益第三步「显示页面」字段一致: • **权益首页/列表页**:圆点单选 显示 / 不显示 • **组装页**:圆点单选 显示 / 不显示,选"显示"时下方多选组装页 A/B/C/D 底部按钮【取消】/【确定】。点击【确定】后立即生效,列表"显示页面"列以 Tag 形式回显,并提示"显示页面已更新" |
 | 更多 → 生成链接 | 任意状态 | 弹出"生成信用卡权益链接"对话框,给出对应 `equity/credit?id=<权益编号>` 形式的可分享链接 |
 
 ### 3.5 分页
@@ -554,7 +559,7 @@
 
 ### 4.1 批次列表字段
 
-![](../../.resources/image-1779247856014.png)
+![BlockNote image](../../.resources/image-1779247856014.png)
 
 | 字段  | 说明  |
 | --- | --- |
@@ -571,7 +576,7 @@
 
 ### 4.2 新增券码批次
 
-![](../../.resources/image-1779247865752.png)
+![BlockNote image](../../.resources/image-1779247865752.png)
 
 点击列表上方【新增券码批次】按钮,显示页面:
 
@@ -590,11 +595,11 @@
 
 **操作入口**:批次行【操作】→【编辑】仅可修改三个时间字段:生效时间、过期时间、停止发放时间。**操作按钮**:【取消】关闭;【确定】保存。
 
-![](../../.resources/image-1779247877545.png)
+![BlockNote image](../../.resources/image-1779247877545.png)
 
 ### 4.4 回收
 
-![](../../.resources/image-1779247893182.png)
+![BlockNote image](../../.resources/image-1779247893182.png)
 
 **展示条件**:批次"券码剩余数量 > 0"时显示【回收】按钮。**交互**:弹出对话框,先选择**回收类型**(单选三选一):
 
@@ -631,7 +636,7 @@
 
 ### 6.2 列表页
 
-![](screenshots/6-audit-list.png)
+![BlockNote image](./screenshots/6-audit-list.png)
 
 按审核状态分为三个 Tab,切换 Tab 时重新加载列表:
 
@@ -690,7 +695,7 @@
 | 上一步 | 当前步骤 > 1 | 回到上一个步骤 |
 | 下一步 | 当前步骤 < 3 | 进入下一个步骤 |
 | 返回  | 始终显示 | 返回审核列表 |
-| 通过  | 审核状态 = `待审核` 时显示。<br><br>**新增类型须当前步骤 = 3 才显示**;下架类型不限制步骤 | 二次确认弹窗 `确定通过该审核单吗?`(按钮 通过/取消) → 提示 `审核通过`,返回审核列表 |
+| 通过  | 审核状态 = `待审核` 时显示。**新增类型须当前步骤 = 3 才显示**;下架类型不限制步骤 | 二次确认弹窗 `确定通过该审核单吗?`(按钮 通过/取消) → 提示 `审核通过`,返回审核列表 |
 | 驳回  | 显示条件同【通过】 | 二次确认弹窗 `确定驳回该审核单吗?`(按钮 驳回/取消) → 提示 `已驳回`,返回审核列表 |
 
 ### 6.4 状态联动
@@ -735,7 +740,7 @@
 
 2.1 列表页
 
-![](screenshots/crowd-task-list.png)
+![BlockNote image](./screenshots/crowd-task-list.png)
 
 **入口**:Liso 后台 → 定制功能 → 汇丰银行 → 客群任务管理。
 
@@ -743,7 +748,7 @@
 
 | 按钮  | 说明  |
 | --- | --- |
-| 新增任务 | 下拉菜单,二选一:  <br>• **规则组圈选**:跳转至 `crowd-tasks/add` 走两步骤新建向导  <br>• **指定客户号**:跳转至 `crowd-name-lists/add` 走两步骤新建向导 |
+| 新增任务 | 下拉菜单,二选一: • **规则组圈选**:跳转至 `crowd-tasks/add` 走两步骤新建向导 • **指定客户号**:跳转至 `crowd-name-lists/add` 走两步骤新建向导 |
 
 **筛选条件**:
 
@@ -779,7 +784,7 @@
 
 2.2.1 规则组圈选
 
-![](screenshots/crowd-task-add-step1.png)
+![BlockNote image](./screenshots/crowd-task-add-step1.png)
 
 **入口**:列表头部「新增任务 → 规则组圈选」。两步骤向导:
 
@@ -812,7 +817,7 @@
 
 2.2.2 指定客户号
 
-![](screenshots/crowd-name-list-add-step1.png)
+![BlockNote image](./screenshots/crowd-name-list-add-step1.png)
 
 **入口**:列表头部「新增任务 → 指定客户号」。两步骤向导:
 
@@ -888,7 +893,7 @@
 
 2.5 任务详情
 
-![](screenshots/crowd-task-view.png)
+![BlockNote image](./screenshots/crowd-task-view.png)
 
 **入口**:列表行内【详情】。
 
@@ -956,7 +961,7 @@
 
 3.1 列表页
 
-![](screenshots/rule-group-list.png)
+![BlockNote image](./screenshots/rule-group-list.png)
 
 **入口**:Liso 后台 → 定制功能 → 汇丰银行 → 规则组管理。
 
@@ -1001,7 +1006,7 @@
 
 3.2 新增 / 编辑规则组
 
-![](screenshots/rule-group-add-step1.png)
+![BlockNote image](./screenshots/rule-group-add-step1.png)
 
 **入口**:列表头部【新增规则组】 / 行内【编辑】。两步骤向导:
 
@@ -1029,7 +1034,7 @@
 
 3.3 规则组详情
 
-![](screenshots/rule-group-view.png)
+![BlockNote image](./screenshots/rule-group-view.png)
 
 **入口**:列表行内【查看详情】。
 
@@ -1066,7 +1071,7 @@
 
 ## 2\. 列表页
 
-![](screenshots/4-grant-campaign-list.png)
+![BlockNote image](./screenshots/4-grant-campaign-list.png)
 
 ### 2.1 筛选条件
 
@@ -1097,7 +1102,7 @@
 | 状态  | Tag 展示,可选值:`未开始`(灰)/ `进行中`(绿)/ `已结束`(默认) |
 | 获取方式 | Tag 展示,可选值:`用户领取`(绿)/ `直接发放`(黄) |
 | 权益数量 | 含周期前缀,例如 `每月 2 个`、`整个周期 1 个`、`每季度 5 个`、`每年 10 个` |
-| 权益有效期 | 固定日期:`YYYY-MM-DD 至 YYYY-MM-DD`;<br><br>相对日期:`自发放起 N 天内有效` |
+| 权益有效期 | 固定日期:`YYYY-MM-DD 至 YYYY-MM-DD`;相对日期:`自发放起 N 天内有效` |
 | 创建时间 | Y-m-d H:i:s |
 
 ### 2.3 操作可见性矩阵
@@ -1129,10 +1134,10 @@
 | 发放计划开始时间 | 是   | datetime 选择器,精确到秒,开始时间必须大于当前时间,格式 `YYYY-MM-DD HH:mm:ss` |
 | 发放计划结束时间 | 是   | datetime 选择器,精确到秒;提交时校验「结束时间必须晚于开始时间」 |
 | 选择客群 | 是   | 多选下拉(支持搜索),已选项以可折叠 Tag 展示;选项格式 `客群名称 (客群编号)`,数据源为人群管理中的客群任务(含规则组圈选与指定客户号两种类型);校验文案 `请至少选择一个客群` |
-| 获取方式 | 是   | 圆点单选,默认 `用户领取`:  <br>• **用户领取**:权益发放到客户名下后由用户主动领取  <br>• **直接发放**:权益自动到账,无需领取动作 |
-| 选择权益 | 是   | 数据源为「已上架」的借记卡权益。选项格式 `权益名称 (权益编号)`  <br>• 用户领取模式:多选下拉(支持搜索,已选项以可折叠 Tag 展示)  <br>• 直接发放模式:单选下拉 + 搜索  <br>切换 `用户领取 → 直接发放` 时,若已选多个权益,自动只保留第一个;校验文案 `请至少选择一个权益` / `请选择一个权益` |
+| 获取方式 | 是   | 圆点单选,默认 `用户领取`: • **用户领取**:权益发放到客户名下后由用户主动领取 • **直接发放**:权益自动到账,无需领取动作 |
+| 选择权益 | 是   | 数据源为「已上架」的借记卡权益。选项格式 `权益名称 (权益编号)` • 用户领取模式:多选下拉(支持搜索,已选项以可折叠 Tag 展示) • 直接发放模式:单选下拉 + 搜索 切换 `用户领取 → 直接发放` 时,若已选多个权益,自动只保留第一个;校验文案 `请至少选择一个权益` / `请选择一个权益` |
 | 权益数量 | 是   | 行内组合控件:左侧周期下拉(整个周期 / 每月 / 每季度 / 每年),右侧数字输入(1 ~ 9999),后缀"个" |
-| 权益有效期 | 是   | 圆点单选「固定日期 / 相对日期」:  <br>• **固定日期**:选择起止日期(必须二选完整)  <br>• **相对日期**:数字输入 1 ~ 3650,单位"天(自发放起算)",默认值 30 |
+| 权益有效期 | 是   | 圆点单选「固定日期 / 相对日期」: • **固定日期**:选择起止日期(必须二选完整) • **相对日期**:数字输入 1 ~ 3650,单位"天(自发放起算)",默认值 30 |
 | 短信通知 | 是   | 短信模板下拉,**专用于发放成功短信**。 |
 
 **底部按钮**(页脚居中):
@@ -1140,13 +1145,13 @@
 | 按钮  | 行为  |
 | --- | --- |
 | 取消  | 直接返回发放计划列表 |
-| 保存  | 触发所有字段必填校验 + 业务校验(结束晚于开始 / 固定日期需选完整 / 相对日期 ≥ 1 / 权益数量 ≥ 1 / 直接发放模式仅允许 1 个权益 / 短信模板必选)。<br><br>校验通过后调用保存接口,新建提示 `新建成功`,编辑提示 `保存成功`,并返回列表页 |
+| 保存  | 触发所有字段必填校验 + 业务校验(结束晚于开始 / 固定日期需选完整 / 相对日期 ≥ 1 / 权益数量 ≥ 1 / 直接发放模式仅允许 1 个权益 / 短信模板必选)。校验通过后调用保存接口,新建提示 `新建成功`,编辑提示 `保存成功`,并返回列表页 |
 
 * * *
 
 ## 4\. 查看发放计划
 
-![](screenshots/4-grant-campaign-view.png)
+![BlockNote image](./screenshots/4-grant-campaign-view.png)
 
 **入口**:列表行【查看】,所有状态均可进入。
 
@@ -1187,7 +1192,7 @@
 
 ### Tab名称配置模块
 
-![](../../.resources/image-1779244508001.png)
+![BlockNote image](../../.resources/image-1779244508001.png)
 
 -   **Tab名称(输入框)**:
     
@@ -1198,7 +1203,7 @@
 
 ### 头部配置
 
-![](../../.resources/image-1779244508002.png)
+![BlockNote image](../../.resources/image-1779244508002.png)
 
 对应移动端最上方的大图与欢迎文案。
 
@@ -1209,7 +1214,7 @@
 
 ### 按钮配置模块
 
-![](../../.resources/image-1779244508003.png)
+![BlockNote image](../../.resources/image-1779244508003.png)
 
 -   **已解锁按钮文案(输入框)**:
     
@@ -1222,7 +1227,7 @@
 
 ### 权益列表配置
 
-![](../../.resources/image-1779244508004.png)
+![BlockNote image](../../.resources/image-1779244508004.png)
 
 -   **配图(上传组件)**:
     
@@ -1235,7 +1240,7 @@
 
 ### 客户身份模块配置
 
-![](../../.resources/image-1779244508005.png)
+![BlockNote image](../../.resources/image-1779244508005.png)
 
 前端将根据登录客户的实际客群 (MSC) 自动匹配展示此处配置的对应身份卡片。支持四个客群的独立配置:运筹理财、卓越理财、卓越理财·尊尚、环球私人银行。
 
@@ -1256,7 +1261,7 @@
 
 ### 活动广告轮播图
 
-![](../../.resources/image-1779244508006.png)
+![BlockNote image](../../.resources/image-1779244508006.png)
 
 对应移动端中部的精选活动卡片滑动区。
 
@@ -1271,14 +1276,14 @@
 
 ### 常用兑换配置
 
-![](../../.resources/image-1779244508007.png)
+![BlockNote image](../../.resources/image-1779244508007.png)
 
 -   **常用兑换**:配置底部横滑图标卡片,支持图片、名称及跳转 URL 的录入。
     
 
 ### 积分规则弹窗配置
 
-![](../../.resources/image-1779244508008.png)
+![BlockNote image](../../.resources/image-1779244508008.png)
 
 -   **积分规则弹窗**:支持通过富文本工具的 **“附件”** 按钮上传本地 PDF。移动端会自动生成带回形针图标的下载链接,点击可调用原生阅读器全屏预览该 PDF 规则细则。
     
@@ -1289,13 +1294,15 @@
 
 ### 列表管理
 
-![](../../.resources/image-1779244508009.png)
+![BlockNote image](../../.resources/image-1779244508009.png)
 
 -   **列表视图**:展示所有组装页的页面ID、名称、发布状态(草稿/已上架)及更新时间。支持按页面ID或名称精准查询。
     
 -   **获取链接**:在列表视图的每一行操作列中,提供了专属的“链接”按钮,点击可直接唤起获取该单页专属分发链接的面板。支持在弹窗的下拉列表中选择 `Campaign ID` 和 `Channel ID`。选择后,会自动将这些参数拼接在 URL 末尾(例如拼接 `&CampaignId=CAMP202511&Channelid=CH_APP`)。若未选择任何下拉值,则不拼接相关参数。
     
-    ![](../../.resources/image-1779244508010.png)
+
+![BlockNote image](../../.resources/image-1779244508010.png)
+
 -   **页面基础信息**:点击“新建”或“编辑”进入详情后,需设置内部管理的“组装页名称”。页面 ID 则由系统自动分配生成,不可修改。
     
 -   **状态与链接区分**:若页面处于“草稿”状态,弹窗中仅提供用于内部预览测试的“草稿预览链接”;当页面正式“发布”后,弹窗会同时展示正式投放使用的“正式环境链接”和草稿预览链接。
@@ -1305,12 +1312,16 @@
 
 -   **进入列表与创建**:在组装页列表中,您可以查看到所有历史配置的页面。点击右上角的【新建组装页】按钮,或点击已有页面的【编辑】进入配置界面。
     
-    ![](../../.resources/image-1779244508011.png)
+
+![BlockNote image](../../.resources/image-1779244508011.png)
+
 -   **设置组装页名称(必填)**:在页面顶部输入框填写该页面的名称。
     
     -   注意:不仅用于后台列表查找,还会**直接在移动端最顶部的导航栏(Page Title)作为标题向客户展示**。
         
-        ![](../../.resources/image-1779244508012.png)
+
+![BlockNote image](../../.resources/image-1779244508012.png)
+
 -   **页面 ID 生成**:系统会自动为当前页面生成一个唯一的“页面 ID”,无需您手动配置。
     
 
@@ -1318,39 +1329,52 @@
 
 -   **模块新增:**在配置区最下方,可以看到四个功能按钮。点击对应的按钮即可在页面最末尾新增该模块:
     
-    ![](../../.resources/image-1779244508013.png)
+
+![BlockNote image](../../.resources/image-1779244508013.png)
+
 -   **模块位置调整与管理**:每个已添加的模块右上角都有一个统一的操作控制台:
     
-    ![](../../.resources/image-1779244508014.png)
-    -   **气泡排序**:点击带有数字的列表图标,在弹出的悬浮气泡中输入目标序号(如:1),点击确定,该模块将立即移动到对应位置。
-        
-    -   **复制**:点击“复制”可一键克隆当前模块及内部所有数据(_注:为防止数据冲突,系统已强行锁死并隐藏了“权益商品模块”的复制功能_)。
-        
-    -   **删除**:点击红色的垃圾桶图标并确认,即可移除不需要的模块。
-        
+
+![BlockNote image](../../.resources/image-1779244508014.png)
+
+-   **气泡排序**:点击带有数字的列表图标,在弹出的悬浮气泡中输入目标序号(如:1),点击确定,该模块将立即移动到对应位置。
+    
+-   **复制**:点击“复制”可一键克隆当前模块及内部所有数据(_注:为防止数据冲突,系统已强行锁死并隐藏了“权益商品模块”的复制功能_)。
+    
+-   **删除**:点击红色的垃圾桶图标并确认,即可移除不需要的模块。
+    
 -   **图片模块**:
     
-    ![](../../.resources/image-1779244508015.png)
-    -   **配置**:通栏视觉展示模块。
-        
-    -   **移动端交互**:强烈建议图片宽度为 `750px`,移动端将 `100%` 自动拉通铺满屏幕宽度。高度无需受限,系统将根据上传图片的原始比例自动计算并拉伸展示,防止变形。
-        
+
+![BlockNote image](../../.resources/image-1779244508015.png)
+
+-   **配置**:通栏视觉展示模块。
+    
+-   **移动端交互**:强烈建议图片宽度为 `750px`,移动端将 `100%` 自动拉通铺满屏幕宽度。高度无需受限,系统将根据上传图片的原始比例自动计算并拉伸展示,防止变形。
+    
 -   **权益商品模块**:**全局唯一,一个页面仅限添加一个,且不支持复制**。可配置一个大区标题,并在其下方添加多个带图标的权益商品明细。
     
-    ![](../../.resources/image-1779244508016.png)
+
+![BlockNote image](../../.resources/image-1779244508016.png)
+
 -   **图文模块**:提供包含完整工具栏的富文本编辑器,支持上传附件和插入外链。
     
-    ![](../../.resources/image-1779244508017.png)
+
+![BlockNote image](../../.resources/image-1779244508017.png)
+
 -   **按钮模块**:
     
     -   **样式选择(下拉框)**:支持“突出显示 (红色背景)”与“次要操作 (白色背景)”两种视觉层级。
         
     -   **动作 - 跳转链接(输入框):**配置按钮直接跳转至指定 URL。提交保存时系统会进行强校验,若动作为跳转链接,则链接地址字段绝对不能为空。
         
-        ![](../../.resources/image-1779244508018.png)
-    -   **动作 - 弹窗提示 (Dialog):**配置点击按钮后唤起屏幕中央的弹窗。支持配置弹窗标题、描述正文,以及右侧“确认”按钮的文案与后续跳转链接。若未配置确认按钮的跳转链接,则点击确认仅执行关闭弹窗的动作。弹窗左侧固定展示取消按钮。
-        
-        ![](../../.resources/image-1779244508019.png)
+
+![BlockNote image](../../.resources/image-1779244508018.png)
+
+-   **动作 - 弹窗提示 (Dialog):**配置点击按钮后唤起屏幕中央的弹窗。支持配置弹窗标题、描述正文,以及右侧“确认”按钮的文案与后续跳转链接。若未配置确认按钮的跳转链接,则点击确认仅执行关闭弹窗的动作。弹窗左侧固定展示取消按钮。
+    
+
+![BlockNote image](../../.resources/image-1779244508019.png)
 
 # 七、渠道管理
 
@@ -1360,14 +1384,15 @@
 
 -   点击页面左中部的蓝色按钮 **【+ 新建 Channel】**,系统将弹出新建表单。
     
-    ![](../../.resources/image-1779258558474.png)
+
+![BlockNote image](../../.resources/image-1779258558474.png)
 
 -   **Channel ID**:系统会在保存时**自动生成**唯一的 ID(如 CH0001),无需手动填写。
     
 -   **选择内置层级属性**(严格的级联选择):
     
 
-![](../../.resources/image-1779258585910.png)
+![BlockNote image](../../.resources/image-1779258585910.png)
 
 -   **Channel 类型 (Type)**:点击下拉框,选择内置的类型(如 `Human-assisted` 或 `Unassisted`)。
     
@@ -1384,7 +1409,7 @@
 -   点击 **【确定】** 保存,系统将在列表中生成一条全新的 Channel 记录。
     
 
-![](../../.resources/image-1779258602589.png)
+![BlockNote image](../../.resources/image-1779258602589.png)
 
 ### 新增全新的 类型/类别/名称(字典维护功能)
 
@@ -1392,16 +1417,14 @@
 
 **新增「Channel 类型」**:
 
-![](../../.resources/image-1779258618794.png)
+![BlockNote image](../../.resources/image-1779258618794.png)
 
 -   点击【Channel 类型】下拉框右侧的“新增”按钮。
     
-
 -   下方会展开一个快捷面板。在输入框中输入全新的类型名称(例如:新的类型)。
     
 -   点击“保存”。系统会自动将该类型存入底层字典,并在下拉框中自动选中它。
     
-
 -   **新增「Channel 类别」**:
     
     -   **前提**:必须先在上方选择或新建好【Channel 类型】。如果未选择类型直接点击新增类别,系统会拦截并提示“_请先选择 Channel 类型_”。
@@ -1419,7 +1442,7 @@
 
 为了保证字典数据的整洁,系统排重校验:
 
-![](../../.resources/image-1779258649960.png)
+![BlockNote image](../../.resources/image-1779258649960.png)
 
 -   **同级查重**:在通过快捷面板保存新的 类型/类别/名称 时,系统会实时校验。如果输入的名称在当前层级已经存在,系统会弹出红色提示 **“该类型/类别/名称已存在”**,并拒绝保存。此时需要修改输入,或者直接取消新增,去下拉框中选择已有的那一项。
     
@@ -1455,7 +1478,7 @@
 -   点击 **【+ 新建 Campaign】**
     
 
-![](../../.resources/image-1779258666277.png)
+![BlockNote image](../../.resources/image-1779258666277.png)
 
 -   **Campaign ID**:同样由系统自动生成(如 CP9001)。
     
@@ -1472,7 +1495,7 @@
 
 ### 列表操作与查询
 
-![](../../.resources/image-1779258680846.png)
+![BlockNote image](../../.resources/image-1779258680846.png)
 
 Campaign 的列表操作与 Channel 保持一致:
 
@@ -1485,7 +1508,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ### 全局页面状态指示器
 
-![](../../.resources/image-1779258695118.png)
+![BlockNote image](../../.resources/image-1779258695118.png)
 
 位于顶部独立工具栏左侧,实时侦测并防呆:
 
@@ -1498,7 +1521,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ### 保存与发布
 
-![](../../.resources/image-1779258710263.png)
+![BlockNote image](../../.resources/image-1779258710263.png)
 
 界面右上方固定提供两个全局核心操作:
 
@@ -1541,7 +1564,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ### 2.1 列表页
 
-![](screenshots/6-sms-template-list.png)
+![BlockNote image](./screenshots/6-sms-template-list.png)
 
 2.1.1 筛选条件
 
@@ -1608,7 +1631,7 @@ Campaign 的列表操作与 Channel 保持一致:
 | 按钮  | 行为  |
 | --- | --- |
 | 取消  | 关闭弹窗,丢弃输入 |
-| 保存  | 触发校验,通过后写入:  <br>• 新建:生成模板 ID(`SMS_TPL_xxx`),提示 `新建成功`  <br>• 编辑:保留原 ID 并刷新"最后更新人 / 更新时间",提示 `保存成功` |
+| 保存  | 触发校验,通过后写入: • 新建:生成模板 ID(`SMS_TPL_xxx`),提示 `新建成功` • 编辑:保留原 ID 并刷新"最后更新人 / 更新时间",提示 `保存成功` |
 
 * * *
 
@@ -1629,7 +1652,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ### 3.1 列表页
 
-![](screenshots/6-sms-send-record.png)
+![BlockNote image](./screenshots/6-sms-send-record.png)
 
 3.1.1 筛选条件
 
@@ -1685,7 +1708,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ## 1\. 积分明细查询
 
-![](screenshots/7-points-detail.png)
+![BlockNote image](./screenshots/7-points-detail.png)
 
 **入口**:Liso 后台 → 定制功能 → 汇丰银行 → 积分明细查询。
 
@@ -1728,7 +1751,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ## 2\. 客户权益发放
 
-![](screenshots/7-customer-equity-grant.png)
+![BlockNote image](./screenshots/7-customer-equity-grant.png)
 
 **入口**:Liso 后台 → 定制功能 → 汇丰银行 → 客户权益发放。查询客户维度的权益发放记录。
 
@@ -1768,7 +1791,7 @@ Campaign 的列表操作与 Channel 保持一致:
 
 ## 3\. 立减金发放明细
 
-![](screenshots/7-cash-discount-grant.png)
+![BlockNote image](./screenshots/7-cash-discount-grant.png)
 
 **入口**:Liso 后台 → 定制功能 → 汇丰银行 → 立减金发放明细。查询立减金发放及使用情况。