Bläddra i källkod

发放计划新增短信通知与获取方式,合并客群名单到客群任务管理

- 发放计划:新增"短信通知"必选项(含 3 个发放成功模板预览)和"获取方式"
  单选(用户领取/直接发放),直接发放限单权益;列表/查看页同步展示
- 客群名单管理合并到客群任务管理:用类型列区分"规则组圈选/指定客户号",
  新增按钮改为下拉选类型;复制、人员调整、上传调整文件三类操作下沉至行操作列
- 上传调整文件:去掉"客群编号"字段,仅保留"客户号|+|调整方向"两段格式,
  目标客群由所在行隐式绑定
- 客群任务新增-初始人员改为必填,支持手动输入或上传文件两种方式
- 移除列表"所属银行"筛选项;修复初始人员步骤的占位符与排版

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fade 1 månad sedan
förälder
incheckning
aace4d7b60

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

@@ -15,7 +15,9 @@
       "Bash(grep -rn \"label=\\\\\"首页\\\\\"\\\\|pages.push\\('首页'\\)\\\\|pages.push\\(\\\\\"首页\\\\\"\\)\" .)",
       "Bash(npx eslint *)",
       "Bash(echo \"exit=$?\")",
-      "Bash(openssl s_client -connect hub.banwotech.cn:443 -servername hub.banwotech.cn)"
+      "Bash(openssl s_client -connect hub.banwotech.cn:443 -servername hub.banwotech.cn)",
+      "Bash(git add *)",
+      "Bash(git commit -m ' *)"
     ],
     "additionalDirectories": []
   }

+ 0 - 3
pc/src/components/Layout.vue

@@ -56,9 +56,6 @@
               <el-menu-item index="/customization/hsbc/rule-groups">
                 规则组管理
               </el-menu-item>
-              <el-menu-item index="/customization/hsbc/crowd-name-lists">
-                客群名单管理
-              </el-menu-item>
             </el-submenu>
             <el-submenu index="customization-hsbc-sms">
               <template slot="title">短信管理</template>

+ 26 - 19
pc/src/components/crowdNameList/UploadAdjustDialog.vue

@@ -9,10 +9,11 @@
     <div class="format-block">
       <div class="format-title">文件格式说明</div>
       <div class="format-line">每行一条调整记录,使用 <code>|+|</code> 作为分隔符:</div>
-      <pre class="format-sample">客户号|+|客群编号|+|调整方向
-00001|+|A0000120251224|+|A
-00002|+|A0000120251224|+|D</pre>
+      <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
@@ -32,14 +33,13 @@
       <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="100" />
-        <el-table-column prop="listId" label="客群编号" />
-        <el-table-column label="调整方向" width="100">
+        <el-table-column prop="customerId" label="客户号" width="120" />
+        <el-table-column label="调整方向" width="110">
           <template slot-scope="scope">
             {{ scope.row.direction === 'A' ? '调增-A' : scope.row.direction === 'D' ? '调减-D' : scope.row.direction }}
           </template>
         </el-table-column>
-        <el-table-column label="状态" width="160">
+        <el-table-column label="状态" min-width="160">
           <template slot-scope="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>
@@ -59,7 +59,7 @@
         <div>失败明细:</div>
         <ul>
           <li v-for="(f, i) in result.failures" :key="i">
-            第 {{ f.line }} 行({{ f.item.customerId }} → {{ f.item.listId }} / {{ f.item.direction }}):{{ f.reason }}
+            第 {{ f.line }} 行({{ f.item.customerId }} / {{ f.item.direction }}):{{ f.reason }}
           </li>
         </ul>
       </div>
@@ -82,7 +82,8 @@ import crowdNameListStore from '@/store/crowdNameListStore'
 export default {
   name: 'UploadAdjustDialog',
   props: {
-    visible: { type: Boolean, default: false }
+    visible: { type: Boolean, default: false },
+    defaultListId: { type: String, default: '' }
   },
   data() {
     return {
@@ -101,6 +102,11 @@ export default {
     },
     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: {
@@ -125,20 +131,21 @@ export default {
       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 !== 3) {
-          return { line: idx + 1, raw: line, error: '格式错误(应为3段,使用 |+| 分隔)' }
+        if (parts.length !== 2) {
+          return { line: idx + 1, raw: line, error: '格式错误(应为2段,使用 |+| 分隔)' }
         }
-        const [customerId, listId, direction] = parts.map((s) => s.trim())
-        if (!customerId) return { line: idx + 1, customerId, listId, direction, error: '客户号为空' }
-        const list = crowdNameListStore.getById(listId)
-        if (!list) return { line: idx + 1, customerId, listId, direction, error: '客群编号不存在' }
-        if (list.status === 'stopped') return { line: idx + 1, customerId, listId, direction, error: '名单已停止' }
+        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, listId, direction, error: '调整方向只能为 A 或 D' }
+          return { line: idx + 1, customerId, direction, error: '调整方向只能为 A 或 D' }
         }
-        return { line: idx + 1, customerId, listId, direction }
+        return { line: idx + 1, customerId, direction }
       })
     },
     handleApply() {
@@ -147,7 +154,7 @@ export default {
         return
       }
       const items = this.validItems.map((r) => ({
-        listId: r.listId,
+        listId: this.defaultListId,
         customerId: r.customerId,
         direction: r.direction
       }))

+ 3 - 1
pc/src/mock/index.js

@@ -981,6 +981,7 @@ const equityGrantCampaignList = [
     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,
@@ -994,7 +995,8 @@ const equityGrantCampaignList = [
     startTime: '2026-04-01 00:00:00',
     endTime: '2026-06-30 23:59:59',
     crowdList: ['A0000120251224'],
-    equityList: ['Q100001', 'Q100003'],
+    grantMode: 'direct',
+    equityList: ['Q100001'],
     grantCycle: 'monthly',
     grantQuantity: 2,
     validity: { type: 'fixed', fixedRange: ['2026-04-01', '2026-06-30'], relativeDays: 30 },

+ 2 - 8
pc/src/router/index.js

@@ -81,23 +81,17 @@ const routes = [
         component: () => import('@/views/CrowdTaskView.vue'),
         meta: { title: '客群任务详情' }
       },
-      {
-        path: 'customization/hsbc/crowd-name-lists',
-        name: 'CrowdNameList',
-        component: () => import('@/views/CrowdNameList.vue'),
-        meta: { title: '客群名单管理', icon: 'el-icon-document' }
-      },
       {
         path: 'customization/hsbc/crowd-name-lists/add',
         name: 'CrowdNameListAdd',
         component: () => import('@/views/CrowdNameListEdit.vue'),
-        meta: { title: '新增客群' }
+        meta: { title: '新增指定客户号客群' }
       },
       {
         path: 'customization/hsbc/crowd-name-lists/edit/:id',
         name: 'CrowdNameListEdit',
         component: () => import('@/views/CrowdNameListEdit.vue'),
-        meta: { title: '编辑客群' }
+        meta: { title: '编辑指定客户号客群' }
       },
       {
         path: 'customization/hsbc/rule-groups',

+ 0 - 170
pc/src/views/CrowdNameList.vue

@@ -1,170 +0,0 @@
-<template>
-  <div class="crowd-name-list">
-    <el-card>
-      <div slot="header" class="search-section">
-        <div class="search-row">
-          <el-form :model="searchForm" label-width="90px" inline>
-            <el-form-item label="所属银行:" required>
-              <el-select v-model="searchForm.bank" placeholder="请选择" style="width: 160px" disabled>
-                <el-option label="汇丰银行" value="hsbc" />
-              </el-select>
-            </el-form-item>
-            <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>
-        </div>
-        <div class="action-row">
-          <el-button type="primary" @click="handleSearch">查询</el-button>
-          <el-button type="primary" icon="el-icon-plus" @click="handleCreate">新增客群</el-button>
-          <el-button type="primary" icon="el-icon-plus" @click="adjustVisible = true">人员调整</el-button>
-          <el-button type="primary" icon="el-icon-plus" @click="uploadVisible = true">上传调整文件</el-button>
-        </div>
-      </div>
-
-      <el-table :data="pagedData" stripe border style="width: 100%">
-        <el-table-column prop="id" label="客群编号" width="170" />
-        <el-table-column prop="name" label="客群名称" min-width="140" show-overflow-tooltip />
-        <el-table-column label="状态" width="100" align="center">
-          <template slot-scope="scope">
-            <el-tag :type="statusTagType(scope.row.status)" size="small">
-              {{ statusLabel(scope.row.status) }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="人数" width="80" align="center">
-          <template slot-scope="scope">{{ (scope.row.members || []).length }}</template>
-        </el-table-column>
-        <el-table-column prop="creator" label="创建人" width="100" />
-        <el-table-column label="审核人" width="100">
-          <template slot-scope="scope">{{ scope.row.reviewer || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="createdAt" label="客群创建时间" width="170" />
-        <el-table-column prop="updatedAt" label="更新时间" width="170" />
-        <el-table-column label="操作" width="160" align="center" fixed="right">
-          <template slot-scope="scope">
-            <el-button
-              v-if="scope.row.status === 'draft'"
-              type="success" circle size="mini" icon="el-icon-check" title="启用"
-              @click="handleActivate(scope.row)"
-            />
-            <el-button
-              v-if="scope.row.status !== 'stopped'"
-              type="primary" circle size="mini" icon="el-icon-edit" title="编辑"
-              @click="handleEdit(scope.row)"
-            />
-            <el-button
-              v-if="scope.row.status !== 'stopped'"
-              type="warning" circle size="mini" icon="el-icon-close" title="停止"
-              @click="handleStop(scope.row)"
-            />
-            <span v-if="scope.row.status === 'stopped'" class="muted">无可执行操作</span>
-          </template>
-        </el-table-column>
-      </el-table>
-
-      <div class="pagination-section">
-        <span class="total">共 {{ filteredData.length }} 条</span>
-        <el-pagination
-          layout="prev, pager, next, sizes, jumper"
-          :current-page.sync="page.current"
-          :page-size.sync="page.size"
-          :page-sizes="[10, 20, 50]"
-          :total="filteredData.length"
-        />
-      </div>
-    </el-card>
-
-    <adjust-member-dialog :visible.sync="adjustVisible" />
-    <upload-adjust-dialog :visible.sync="uploadVisible" />
-  </div>
-</template>
-
-<script>
-import crowdNameListStore from '@/store/crowdNameListStore'
-import AdjustMemberDialog from '@/components/crowdNameList/AdjustMemberDialog.vue'
-import UploadAdjustDialog from '@/components/crowdNameList/UploadAdjustDialog.vue'
-
-export default {
-  name: 'CrowdNameList',
-  components: { AdjustMemberDialog, UploadAdjustDialog },
-  data() {
-    return {
-      searchForm: { bank: 'hsbc', id: '', name: '' },
-      page: { current: 1, size: 10 },
-      adjustVisible: false,
-      uploadVisible: false
-    }
-  },
-  computed: {
-    filteredData() {
-      return crowdNameListStore.query({ id: this.searchForm.id, name: this.searchForm.name })
-    },
-    pagedData() {
-      const start = (this.page.current - 1) * this.page.size
-      return this.filteredData.slice(start, start + this.page.size)
-    }
-  },
-  methods: {
-    statusLabel(s) {
-      return { draft: '新建', active: '已生效', stopped: '已停止' }[s] || s
-    },
-    statusTagType(s) {
-      return { draft: 'info', active: 'success', stopped: 'danger' }[s] || ''
-    },
-    handleSearch() {
-      this.page.current = 1
-    },
-    handleCreate() {
-      this.$router.push('/customization/hsbc/crowd-name-lists/add')
-    },
-    handleEdit(row) {
-      this.$router.push(`/customization/hsbc/crowd-name-lists/edit/${row.id}`)
-    },
-    handleActivate(row) {
-      this.$confirm(`启用名单「${row.name}」需通过审核后生效,确认提交?`, '启用', {
-        confirmButtonText: '提交',
-        cancelButtonText: '取消',
-        type: 'info'
-      }).then(() => {
-        crowdNameListStore.activate(row.id)
-        this.$message.success('已生效(demo 默认通过审核)')
-      }).catch(() => {})
-    },
-    handleStop(row) {
-      this.$confirm(`停止后名单不可再调整,确认停止「${row.name}」?`, '停止', {
-        confirmButtonText: '停止',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        crowdNameListStore.stop(row.id)
-        this.$message.success('已停止')
-      }).catch(() => {})
-    }
-  }
-}
-</script>
-
-<style scoped>
-.crowd-name-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>

+ 92 - 31
pc/src/views/CrowdNameListEdit.vue

@@ -7,7 +7,7 @@
 
       <el-steps v-if="!isEdit" :active="step" finish-status="success" align-center class="steps">
         <el-step title="基本信息" icon="el-icon-edit-outline" />
-        <el-step title="初始人员(可选)" icon="el-icon-user" />
+        <el-step title="初始人员" icon="el-icon-user" />
       </el-steps>
 
       <!-- Step 1: 基本信息 -->
@@ -49,19 +49,45 @@
 
       <!-- Step 2: 初始人员(仅新增可见) -->
       <div v-show="step === 1 && !isEdit" class="section">
-        <div class="section-title">初始人员(可选)</div>
-        <div class="hint">
-          每行一个客户号,留空则创建一个空名单。后续可通过列表页"人员调整"或"上传调整文件"补充人员。
+        <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>
-        <el-input
-          v-model="form.initialMembersText"
-          type="textarea"
-          :rows="8"
-          placeholder="示例:&#10;00001&#10;00002&#10;00003"
-          style="max-width: 480px"
-        />
-        <div class="member-stats" v-if="parsedMembers.length > 0">
-          已识别 <b>{{ parsedMembers.length }}</b> 个有效客户号
+
+        <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"
+          >
+            <i class="el-icon-upload"></i>
+            <div class="el-upload__text">将文件拖到此处,或<em>点击选择文件</em></div>
+            <div slot="tip" class="el-upload__tip">
+              仅支持 .txt / .csv,每行一个客户号,UTF-8 编码
+              <span v-if="form.fileName" class="file-name">已选择:{{ form.fileName }}</span>
+            </div>
+          </el-upload>
+
+          <div class="member-stats" v-if="parsedMembers.length > 0">
+            已识别 <b>{{ parsedMembers.length }}</b> 个有效客户号
+          </div>
         </div>
       </div>
 
@@ -83,7 +109,9 @@ const emptyForm = () => ({
   id: '',
   name: '',
   description: '',
-  initialMembersText: ''
+  manualText: '',
+  fileMembers: [],
+  fileName: ''
 })
 
 export default {
@@ -91,6 +119,7 @@ export default {
   data() {
     return {
       step: 0,
+      inputMode: 'manual',
       form: emptyForm(),
       current: null,
       rules: {
@@ -106,8 +135,12 @@ export default {
     pageTitle() {
       return this.isEdit ? '编辑客群' : '新增客群'
     },
+    manualPlaceholder() {
+      return '示例:\n00001\n00002\n00003'
+    },
     parsedMembers() {
-      const text = this.form.initialMembersText || ''
+      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) => {
@@ -125,12 +158,12 @@ export default {
       const target = crowdNameListStore.getById(this.$route.params.id)
       if (!target) {
         this.$message.error('客群不存在')
-        this.$router.replace('/customization/hsbc/crowd-name-lists')
+        this.$router.replace('/customization/hsbc/crowd-tasks')
         return
       }
       if (target.status === 'stopped') {
         this.$message.warning('已停止名单不可编辑')
-        this.$router.replace('/customization/hsbc/crowd-name-lists')
+        this.$router.replace('/customization/hsbc/crowd-tasks')
         return
       }
       this.current = target
@@ -138,7 +171,9 @@ export default {
         id: target.id,
         name: target.name,
         description: target.description,
-        initialMembersText: ''
+        manualText: '',
+        fileMembers: [],
+        fileName: ''
       }
     } else {
       this.form.id = '保存后系统自动生成'
@@ -150,12 +185,30 @@ export default {
         if (ok) this.step = 1
       })
     },
-    handleSave() {
-      if (this.step === 0 && !this.isEdit) {
-        // 不应直接到保存(按钮控制只在 step 1 出现),兜底
-        return
+    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) {
+          this.$message.warning('文件未解析出有效客户号')
+        } else {
+          this.$message.success(`已解析 ${out.length} 个有效客户号`)
+        }
       }
-      // 编辑路径直接走基本信息保存
+      reader.readAsText(file.raw, 'utf-8')
+    },
+    handleSave() {
       if (this.isEdit) {
         this.$refs.baseForm.validate((ok) => {
           if (!ok) return
@@ -164,23 +217,27 @@ export default {
             description: this.form.description
           })
           this.$message.success('保存成功')
-          this.$router.push('/customization/hsbc/crowd-name-lists')
+          this.$router.push('/customization/hsbc/crowd-tasks')
         })
         return
       }
       // 新增路径
+      if (this.parsedMembers.length === 0) {
+        this.$message.error(
+          this.inputMode === 'file' ? '请上传文件并确认包含至少 1 个有效客户号' : '请至少输入 1 个客户号'
+        )
+        return
+      }
       crowdNameListStore.create({
         name: this.form.name,
         description: this.form.description,
         members: this.parsedMembers
       })
-      this.$message.success(this.parsedMembers.length > 0
-        ? `创建成功,初始人员 ${this.parsedMembers.length} 人`
-        : '创建成功')
-      this.$router.push('/customization/hsbc/crowd-name-lists')
+      this.$message.success(`创建成功,初始人员 ${this.parsedMembers.length} 人`)
+      this.$router.push('/customization/hsbc/crowd-tasks')
     },
     handleCancel() {
-      this.$router.push('/customization/hsbc/crowd-name-lists')
+      this.$router.push('/customization/hsbc/crowd-tasks')
     }
   }
 }
@@ -199,8 +256,12 @@ export default {
   background: #f5f7fa; padding: 10px 14px; border-radius: 4px;
   margin-bottom: 16px;
 }
-.hint { color: #909399; font-size: 12px; margin-bottom: 8px; }
-.member-stats { margin-top: 8px; color: #67C23A; font-size: 13px; }
+.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; }

+ 178 - 63
pc/src/views/CrowdTaskList.vue

@@ -4,43 +4,63 @@
       <div slot="header" class="search-section">
         <div class="search-row">
           <el-form :model="searchForm" label-width="90px" inline>
-            <el-form-item label="所属银行:" required>
-              <el-select v-model="searchForm.bank" placeholder="请选择" style="width: 160px" disabled>
-                <el-option label="汇丰银行" value="hsbc" />
-              </el-select>
-            </el-form-item>
             <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-button type="primary" icon="el-icon-plus" @click="handleCreate">新增任务</el-button>
-          <el-button type="primary" icon="el-icon-plus" plain @click="handleCopy">复制任务</el-button>
+          <el-dropdown trigger="click" @command="handleCreateByType">
+            <el-button type="primary" icon="el-icon-plus">
+              新增任务<i class="el-icon-arrow-down el-icon--right"></i>
+            </el-button>
+            <el-dropdown-menu slot="dropdown">
+              <el-dropdown-item command="rule_group">规则组圈选</el-dropdown-item>
+              <el-dropdown-item command="name_list">指定客户号</el-dropdown-item>
+            </el-dropdown-menu>
+          </el-dropdown>
         </div>
       </div>
 
       <el-table
         :data="pagedData"
-        stripe border highlight-current-row
+        stripe border
         style="width: 100%"
-        @current-change="onRowSelect"
       >
         <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 slot-scope="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 slot-scope="scope">
-            <el-tag :type="statusTagType(scope.row.status)" size="small">
-              {{ statusLabel(scope.row.status) }}
+            <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 slot-scope="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 slot-scope="scope">{{ scope.row.reviewer || '-' }}</template>
@@ -48,37 +68,76 @@
         <el-table-column prop="createdAt" label="任务创建时间" width="170" />
         <el-table-column label="任务开始/结束日期" width="190" align="center">
           <template slot-scope="scope">
-            {{ scope.row.startDate }} / {{ scope.row.endDate }}
+            <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="180" fixed="right" align="center">
+        <el-table-column label="操作" width="280" fixed="right" align="center">
           <template slot-scope="scope">
-            <el-button
-              v-if="canDo(scope.row, 'edit')"
-              type="primary" circle size="mini" icon="el-icon-edit" title="编辑"
-              @click.stop="handleEdit(scope.row)"
-            />
-            <el-button
-              v-if="canDo(scope.row, 'run')"
-              type="success" circle size="mini" icon="el-icon-video-play" title="运行"
-              @click.stop="handleRun(scope.row)"
-            />
-            <el-button
-              v-if="canDo(scope.row, 'publish')"
-              type="success" circle size="mini" icon="el-icon-upload2" title="上线"
-              @click.stop="handlePublish(scope.row)"
-            />
-            <el-button
-              v-if="canDo(scope.row, 'stop')"
-              type="warning" circle size="mini" icon="el-icon-close" title="停止"
-              @click.stop="handleStop(scope.row)"
-            />
-            <el-button
-              v-if="canDo(scope.row, 'detail')"
-              type="info" circle size="mini" icon="el-icon-view" title="详情"
-              @click.stop="handleDetail(scope.row)"
-            />
+            <!-- 规则组圈选 -->
+            <template v-if="scope.row.type === 'rule_group'">
+              <el-button
+                v-if="canDoTask(scope.row, 'edit')"
+                type="primary" circle size="mini" icon="el-icon-edit" title="编辑"
+                @click.stop="handleEditTask(scope.row)"
+              />
+              <el-button
+                v-if="canDoTask(scope.row, 'run')"
+                type="success" circle size="mini" icon="el-icon-video-play" title="运行"
+                @click.stop="handleRun(scope.row)"
+              />
+              <el-button
+                v-if="canDoTask(scope.row, 'publish')"
+                type="success" circle size="mini" icon="el-icon-upload2" title="上线"
+                @click.stop="handlePublish(scope.row)"
+              />
+              <el-button
+                v-if="canDoTask(scope.row, 'stop')"
+                type="warning" circle size="mini" icon="el-icon-close" title="停止"
+                @click.stop="handleStop(scope.row)"
+              />
+              <el-button
+                v-if="canDoTask(scope.row, 'detail')"
+                type="info" circle size="mini" icon="el-icon-view" title="详情"
+                @click.stop="handleDetail(scope.row)"
+              />
+              <el-button
+                type="primary" plain circle size="mini" icon="el-icon-document-copy" title="复制"
+                @click.stop="handleCopy(scope.row)"
+              />
+            </template>
+            <!-- 指定客户号 -->
+            <template v-else>
+              <el-button
+                v-if="scope.row.status === 'draft'"
+                type="success" circle size="mini" icon="el-icon-check" title="启用"
+                @click.stop="handleActivate(scope.row)"
+              />
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" circle size="mini" icon="el-icon-edit" title="编辑"
+                @click.stop="handleEditNameList(scope.row)"
+              />
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" plain circle size="mini" icon="el-icon-user" title="人员调整"
+                @click.stop="openAdjust(scope.row)"
+              />
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="primary" plain circle size="mini" icon="el-icon-upload2" title="上传调整文件"
+                @click.stop="openUpload(scope.row)"
+              />
+              <el-button
+                v-if="scope.row.status !== 'stopped'"
+                type="warning" circle size="mini" icon="el-icon-close" title="停止"
+                @click.stop="handleStopNameList(scope.row)"
+              />
+              <span v-if="scope.row.status === 'stopped'" class="muted">无可执行操作</span>
+            </template>
           </template>
         </el-table-column>
       </el-table>
@@ -94,24 +153,48 @@
         />
       </div>
     </el-card>
+
+    <adjust-member-dialog
+      :visible.sync="adjustVisible"
+      :default-list-id="adjustListId"
+    />
+    <upload-adjust-dialog
+      :visible.sync="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'
 
 export default {
   name: 'CrowdTaskList',
+  components: { AdjustMemberDialog, UploadAdjustDialog },
   data() {
     return {
-      searchForm: { bank: 'hsbc', id: '', name: '' },
+      searchForm: { id: '', name: '', type: '' },
       page: { current: 1, size: 10 },
-      selectedRow: null
+      adjustVisible: false,
+      adjustListId: '',
+      uploadVisible: false
     }
   },
   computed: {
     filteredData() {
-      return crowdTaskStore.query({ id: this.searchForm.id, name: this.searchForm.name })
+      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
@@ -119,44 +202,58 @@ export default {
     }
   },
   methods: {
-    statusLabel(s) {
-      return { draft: '新建', verifying: '校验', online: '上线', stopped: '已停止' }[s] || s
+    typeLabel(t) {
+      return t === 'rule_group' ? '规则组圈选' : '指定客户号'
     },
-    statusTagType(s) {
-      return { draft: 'info', verifying: 'warning', online: 'success', stopped: 'danger' }[s] || ''
+    statusLabel(row) {
+      if (row.type === 'name_list') {
+        return { draft: '新建', active: '已生效', stopped: '已停止' }[row.status] || row.status
+      }
+      return { draft: '新建', verifying: '校验', online: '上线', stopped: '已停止' }[row.status] || row.status
     },
-    canDo(row, action) {
-      return crowdTaskStore.allowedActions(row).includes(action)
+    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] || ''
     },
-    onRowSelect(row) {
-      this.selectedRow = row
+    canDoTask(row, action) {
+      return crowdTaskStore.allowedActions(row).includes(action)
     },
     handleSearch() {
       this.page.current = 1
     },
-    handleCreate() {
-      this.$router.push('/customization/hsbc/crowd-tasks/add')
-    },
-    handleCopy() {
-      if (!this.selectedRow) {
-        this.$message.warning('请先在列表中选择一条任务再点击复制')
-        return
+    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) {
       this.$confirm(
-        `确定要复制任务「${this.selectedRow.name}」吗?将生成一条状态为「新建」的副本。`,
+        `确定要复制任务「${row.name}」吗?将生成一条状态为「新建」的副本。`,
         '复制任务',
         { confirmButtonText: '复制', cancelButtonText: '取消', type: 'info' }
       ).then(() => {
-        const copy = crowdTaskStore.copyFrom(this.selectedRow.id)
-        if (copy) {
-          this.$message.success(`已复制为 ${copy.id}`)
-          this.selectedRow = null
-        }
+        const copy = crowdTaskStore.copyFrom(row.id)
+        if (copy) this.$message.success(`已复制为 ${copy.id}`)
       }).catch(() => {})
     },
-    handleEdit(row) {
+    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}`)
     },
@@ -189,6 +286,22 @@ export default {
         crowdTaskStore.stop(row.id)
         this.$message.success('已停止')
       }).catch(() => {})
+    },
+    handleActivate(row) {
+      this.$confirm(`启用名单「${row.name}」需通过审核后生效,确认提交?`, '启用', {
+        confirmButtonText: '提交', cancelButtonText: '取消', type: 'info'
+      }).then(() => {
+        crowdNameListStore.activate(row.id)
+        this.$message.success('已生效(demo 默认通过审核)')
+      }).catch(() => {})
+    },
+    handleStopNameList(row) {
+      this.$confirm(`停止后名单不可再调整,确认停止「${row.name}」?`, '停止', {
+        confirmButtonText: '停止', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        crowdNameListStore.stop(row.id)
+        this.$message.success('已停止')
+      }).catch(() => {})
     }
   }
 }
@@ -203,6 +316,8 @@ export default {
 
 .action-row { margin-top: 8px; display: flex; gap: 8px; }
 
+.muted { color: #c0c4cc; font-size: 12px; }
+
 .pagination-section {
   display: flex;
   justify-content: flex-end;

+ 8 - 1
pc/src/views/EquityGrantCampaign.vue

@@ -71,7 +71,14 @@
             <el-tag :type="statusTag(scope.row)" size="small">{{ statusLabel(scope.row) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="权益可领取数" width="150" align="center">
+        <el-table-column label="获取方式" width="110" align="center">
+          <template slot-scope="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 slot-scope="scope">
             {{ grantSummary(scope.row) }}
           </template>

+ 108 - 5
pc/src/views/EquityGrantCampaignEdit.vue

@@ -59,8 +59,15 @@
             />
           </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
@@ -75,8 +82,22 @@
               :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>
+        <el-form-item label="权益数" required>
           <div class="grant-row">
             <el-select v-model="form.grantCycle" placeholder="类型" style="width:140px">
               <el-option label="整个周期" value="total" />
@@ -121,6 +142,22 @@
             <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">
@@ -142,19 +179,29 @@ function emptyForm() {
     startTime: '',
     endTime: '',
     crowdList: [],
+    grantMode: 'claim',
     equityList: [],
     grantCycle: 'total',
     grantQuantity: 1,
-    validity: { type: 'fixed', fixedRange: [], relativeDays: 30 }
+    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' }],
@@ -162,8 +209,22 @@ export default {
         crowdList: [
           { type: 'array', required: true, message: '请至少选择一个客群', trigger: 'change' }
         ],
+        grantMode: [
+          { required: true, message: '请选择获取方式', trigger: 'change' }
+        ],
         equityList: [
-          { type: 'array', required: true, message: '请至少选择一个权益', trigger: 'change' }
+          {
+            validator: (rule, value, cb) => {
+              if (!value || value.length === 0) {
+                return cb(new Error(this.isClaimMode ? '请至少选择一个权益' : '请选择一个权益'))
+              }
+              cb()
+            },
+            trigger: 'change'
+          }
+        ],
+        smsTemplateId: [
+          { required: true, message: '请选择短信模板', trigger: 'change' }
         ]
       }
     }
@@ -177,6 +238,17 @@ export default {
     },
     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() {
@@ -212,6 +284,7 @@ export default {
         startTime: row.startTime,
         endTime: row.endTime,
         crowdList: [...(row.crowdList || [])],
+        grantMode: row.grantMode || 'claim',
         equityList: [...(row.equityList || [])],
         grantCycle: row.grantCycle || 'total',
         grantQuantity: row.grantQuantity || 1,
@@ -219,9 +292,20 @@ export default {
           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
@@ -240,7 +324,15 @@ export default {
           return
         }
         if (!this.form.grantQuantity || this.form.grantQuantity < 1) {
-          this.$message.warning('请输入权益可领取数')
+          this.$message.warning('请输入权益数量')
+          return
+        }
+        if (!this.isClaimMode && this.form.equityList.length !== 1) {
+          this.$message.warning('直接发放仅支持选择一个权益')
+          return
+        }
+        if (!this.form.smsTemplateId) {
+          this.$message.warning('请选择短信模板')
           return
         }
         await request.post('/api/equity-grant-campaign/save', this.form)
@@ -269,4 +361,15 @@ export default {
   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>

+ 36 - 1
pc/src/views/EquityGrantCampaignView.vue

@@ -11,7 +11,10 @@
         <el-descriptions-item label="状态">
           <el-tag :type="statusTag" size="small">{{ statusLabel }}</el-tag>
         </el-descriptions-item>
-        <el-descriptions-item label="权益可领取数">{{ grantSummary }}</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>
@@ -38,6 +41,12 @@
           </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>
@@ -55,6 +64,12 @@ 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() {
@@ -81,6 +96,13 @@ export default {
       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 '-'
@@ -137,4 +159,17 @@ export default {
   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>