فهرست منبع

docs: 自营管理菜单设计文档(票种/景点/自营分销商/销售日历)

LaveyD 3 هفته پیش
والد
کامیت
928f9d8d67
1فایلهای تغییر یافته به همراه240 افزوده شده و 0 حذف شده
  1. 240 0
      docs/superpowers/specs/2026-10-02-self-manage-menu-design.md

+ 240 - 0
docs/superpowers/specs/2026-10-02-self-manage-menu-design.md

@@ -0,0 +1,240 @@
+# 设计:自营管理菜单(票种 / 景点 / 自营分销商 / 销售日历)
+
+- 日期:2026-10-02
+- 仓库:E:\Data\Code\tourism\ticket-ota(clear-admin,Vue 2.5 + Element UI 2.15.3)
+- 需求来源(m00042):「新增菜单 一级菜单 自营管理(放在产品中心下面) 二级菜单 票种管理 景点管理 自营分销商管理 用户端接口」
+- 接口依据:`自营售票管理端接口.md`(仓库根,593 行,下文简称「管理端文档」)
+- 范围外:微信小程序/公众号 C 端流程(用户明确「微信小程序的不用管」);管理端统计/报表(文档第 7 节明确沿用现有数据中心页面,不新增菜单);现有订单模块(自营订单沿用)
+
+## 1. 背景与现状
+
+- 现有菜单由 `src/router/index.js` 的 `routerMap` 声明,登录后 `src/permission.js` 守卫触发 `GetInfo` → `GenerateRoutes`(`src/store/modules/permission.js`)按后台 `resourceList` 的 code 过滤,`addRoutes` 注入。
+- 菜单图标用 iconfont(`static/font/iconfont.css`,index.html 引入),`meta.icon` → 类名 `icon-{name}`;可用字形含 `icon-ticket-manage`(门票图标),直接复用,零图标改动。
+- 页面惯例:`.form-wrap > .searchBox(el-form inline + .btn-wrap)` + `.tableBox(el-table stripe + 操作列 fixed right)` + `el-pagination`;弹窗拆独立组件(参考 `src/views/goodsCenter/priceList/EditDialog.vue`、`KSNewDialog.vue`)。
+- 请求层:`src/utils/request.js` axios 实例,请求头 `SaToken: localStorage.otaToken`,`code!==200` 弹 Message,401/403 登出。管理端文档确认新接口走同一体系(`/tenant` 前缀 + 现有 satoken),直接复用,无需新实例。
+- 图片上传现成机制:`el-upload picture-card` + `window.config.uploadUrl || 'https://pres.cleartv.cn/upload'`(见 `src/views/goodsCenter/priceList/KSNewDialog.vue:699-702`)。
+- 线下票种列表 `/tenant/otaTicketType` 已有封装:`src/api/ticketType.js` 的 `getTicketTypeList`。
+
+### 权限过滤的关键坑(决定菜单怎么声明)
+
+`src/store/modules/permission.js:19-25` 子路由过滤逻辑:
+
+```js
+route.children = route.children.filter(i => {
+  if (i.meta && i.meta.permissionName) {
+    return permissionCodes.find((code) => i.meta.permissionName.indexOf(`:${code}`) > -1)
+  } else {
+    return true
+  }
+})
+```
+
+- 子路由 `meta.permissionName` 一旦有值,后台 code 必须以 `:code` 形式出现在其中才显示;写 `'all'` 也永远匹配不上 → 子菜单被隐藏。
+- 父级显示条件(`src/router/index.js:281-296` 聚合后):`meta.permissionName` 数组含 `'all'` 则恒显示。
+
+**结论:4 个新子路由均不写 permissionName** → 聚合后父级 `自营管理` 的 permissionName 数组全为 `'all'` → 菜单全员可见。后续若要按角色控制,再补 permissionName + 后端配置 resourceList code。
+
+## 2. 范围(YAGNI)
+
+做:
+
+1. 一级菜单「自营管理」(产品中心之后),二级 4 个页面:票种管理、景点管理、自营分销商管理、销售日历(销售日历是独立第 4 个菜单,用户已确认)。
+2. `src/api/selfManage.js` 新 API 封装。
+3. `src/const.js` 追加本模块常量。
+
+不做:
+
+- 不引富文本库(用户已确认:图文描述/购买协议用 textarea 纯文本,图片用 el-upload)。
+- 不做统计/报表新页面(沿用数据中心现有页)。
+- 不改登录、权限过滤代码、request 拦截器。
+- 不碰工作区既有未提交改动(`src/views/goodsCenter/priceList/EditDialog.vue`、`src/views/queryReport/orderList.vue`)。
+- 零新增 npm 依赖(webpack 3/babel 6 老构建不冒险)。
+
+## 3. 路由(src/router/index.js)
+
+`routerMap` 中「产品中心」块之后、「数据中心」块之前插入:
+
+```js
+{
+  path: '/selfManage',
+  component: Layout,
+  name: '自营管理',
+  meta: { title: '自营管理', icon: 'ticket-manage' },
+  redirect: 'noredirect',
+  alwaysShow: true,
+  children: [
+    { path: 'ticketType', name: 'selfTicketType', component: () => import('@/views/selfManage/ticketType'), meta: { title: '票种管理' } },
+    { path: 'scenic', name: 'selfScenic', component: () => import('@/views/selfManage/scenic'), meta: { title: '景点管理' } },
+    { path: 'thirdParty', name: 'selfThirdParty', component: () => import('@/views/selfManage/thirdParty'), meta: { title: '自营分销商管理' } },
+    { path: 'saleCalendar', name: 'selfSaleCalendar', component: () => import('@/views/selfManage/saleCalendar'), meta: { title: '销售日历' } }
+  ]
+}
+```
+
+注意:`name` 用英文(现有中文 name 与英文混用,新模块统一英文,避免路由跳转歧义);子路由不写 permissionName(见第 1 节)。
+
+## 4. API 层(src/api/selfManage.js,新文件)
+
+统一 `http.post(url, { data: params })` 风格。函数清单(URL 均出自管理端文档):
+
+| 函数 | 方法+URL | 说明 |
+|---|---|---|
+| getSelfScenicList | POST /tenant/selfScenic | 筛选 name(模糊)/isOpen;分页 records/total |
+| addSelfScenic | POST /tenant/selfScenic/add | |
+| updateSelfScenic | POST /tenant/selfScenic/update | 多带 id |
+| deleteSelfScenic | POST /tenant/selfScenic/delete | data.idList |
+| getSelfTicketTypeList | POST /tenant/selfTicketType | 筛选 category/name/isSale;分页 |
+| getSelfTicketTypeDetail | POST /tenant/selfTicketType/{id} | 路径拼 id,body data 空对象 |
+| addSelfTicketType | POST /tenant/selfTicketType/add | |
+| updateSelfTicketType | POST /tenant/selfTicketType/update | 必填 id;ticketTypeId/category 不可改 |
+| deleteSelfTicketType | POST /tenant/selfTicketType/delete | data.idList |
+| getSaleCalendarList | POST /tenant/selfTicketSaleCalendar/list | 入参 selfTicketTypeId;返回数组(非分页) |
+| saveSaleCalendar | POST /tenant/selfTicketSaleCalendar/add | **data 是数组,全量覆盖**;空数组禁止调用 |
+| deleteSaleCalendar | POST /tenant/selfTicketSaleCalendar/delete | data.idList = 规则 id |
+| getUserThirdPartyList | POST /tenant/userThirdParty | 筛选 name(模糊)/contactName/phone/remark/status;分页 |
+| addUserThirdParty | POST /tenant/userThirdParty/add | |
+| updateUserThirdParty | POST /tenant/userThirdParty/update | id + 可选字段;name/loginName 不可改 |
+| bindUserThirdParty | POST /tenant/userThirdParty/bind | userThirdPartyIdList + selfTicketTypeIdList([] = 清空) |
+| rechargeUserThirdParty | POST /tenant/userThirdParty/recharge | userThirdPartyId + amount>0 |
+| decreaseUserThirdParty | POST /tenant/userThirdParty/decrease | 同充值 |
+| deleteUserThirdParty | POST /tenant/userThirdParty/delete | data.idList;余额>0/有未完结订单会失败 |
+| getThirdPartyBalanceLog | POST /tenant/thirdPartyBalanceLog | 筛选 userThirdPartyId/type/tradeTimeStart/tradeTimeEnd;分页 |
+
+线下票种下拉复用 `src/api/ticketType.js` 的 `getTicketTypeList`(POST /tenant/otaTicketType,传 `{}` 拉全量或按 name 筛),不重复封装。
+
+## 5. 常量(src/const.js 追加)
+
+```js
+SELF_TICKET_CATEGORY: ticket 景区门票 / batch 场次售票 / member 年卡次卡
+SELF_REAL_NAME_TYPE: 0 不限 / 1 一证一人 / 2 一证多人
+SELF_CHANNELS: wx 微信公众号 / wx-mini 微信小程序 / third-party 自营分销商
+SELF_WEEKDAYS: 1 周日 … 7 周六(文档约定 1=周日)
+SELF_BALANCE_LOG_TYPE: 1 充值 / 2 人工扣款 / 3 消费 / 4 退款
+SELF_USE_DATE_TYPE: 1 日历票 / 2 期票
+SELF_MEMBER_USE_DATE_TYPE: 1 按天 / 2 固定日期
+SELF_MEMBER_ACTIVE_TYPE: 1 购买即激活 / 2 首次使用激活
+```
+
+## 6. 页面设计
+
+### 6.1 票种管理 `src/views/selfManage/ticketType/index.vue` + `FormDialog.vue`
+
+列表(POST /tenant/selfTicketType):
+
+- 搜索:大类(三选)、名称(模糊)、是否在售(1/0/全部);按钮:重置/搜索/新增/批量删除
+- 表格列:名称 / 大类(tag) / 线下票种id(ticketTypeId) / 底价(price) / 划线价(highPrice) / 在售(tag) / 实名(tag 不限/一证一人/一证多人) / 可售日期(saleDateStart~saleDateEnd) / 渠道(tags) / 绑定景点(scenicList 名称拼接) / 可退 / 创建时间
+- 操作列(fixed right):编辑、销售日历(`$router.push({path:'/selfManage/saleCalendar', query:{id}})`)、删除
+- 删除:单条二次确认;顶部批量删除(selection 列)
+
+表单弹窗 FormDialog(新增/编辑共用,`show(row?)` 模式):
+
+- 新增:选线下票种(getTicketTypeList 下拉,显示 name,提交 ticketTypeId + category);已创建过的后端报「票种已存在」,前端把已创建票种(getSelfTicketTypeList 全量)置 disabled 并提示
+- 选定后 category 锁定为只读展示(创建后不可改)
+- 公共必填:name、price(底价,数字 >0)、realNameType、saleDateStart/saleDateEnd(date 选择器 yyyy-MM-dd)、saleTimeStart/saleTimeEnd(time 选择器 HH:mm:ss,默认 00:00:00~23:59:59)
+- 公共选填:isSale(switch 默认 1)、highPrice、saleMaxPreDays、saleMinPreDays、saleMinNums、saleMaxNums、dayStock、totalStock(0 不限)、isAllowCancel(switch 默认 1)、isAgreeProtocol(switch 默认 0)+ buyProtocol(textarea 纯文本)
+- 渠道:el-checkbox-group 勾 wx/wx-mini/third-party(要给分销商卖必须勾 third-party,文案提示)
+- 景点:el-select multiple,数据源 getSelfScenicList 全量,提交 scenicIds 数组
+- 按大类条件块:
+  - `ticket`:useDays(数字)、useDateType(1 日历票 / 2 期票);期票时 show useDateStart/useDateEnd
+  - `member`:memberUseDateType(1 按天 / 2 固定日期);按天 show useDays;固定日期 show useDateStart/useDateEnd;memberActiveType(1/2)
+  - `batch`:展示所选线下票种的 batchConfigList(只读表格:场次名称/时段/最大数量),说明「场次来自线下票种,不在此维护」
+- 编辑:getSelfTicketTypeDetail 回填;ticketTypeId/category 只读;景点、渠道整表覆盖提交(清空传空数组)
+- 提交前前端校验:必填项、价格/库存数字、日期区间合法
+
+### 6.2 景点管理 `src/views/selfManage/scenic/index.vue` + `FormDialog.vue`
+
+列表(POST /tenant/selfScenic):
+
+- 搜索:名称(模糊)、是否开放(1/0/全部);按钮:重置/搜索/新增/批量删除
+- 表格列:名称 / 地址 / 开放时间(openingTime) / 轮播图(picture 首图缩略 + 张数) / 开放状态(tag 开放/关闭) / 公告(publicAnnouncement 截断,title 悬浮全文) / 创建时间
+- 操作列:编辑(用列表行数据回填,无详情接口)、删除
+- 删除二次确认提示:「已绑到票种的景点删除后,C 端景点列表可能不再展示该景点」
+
+表单弹窗:
+
+- name 必填、isOpen(radio 1/0 必填)、address、openingTime(文本,如 08:00~18:00)
+- picture:el-upload picture-card,`:limit="6"`,action = `window.config.uploadUrl || 'https://pres.cleartv.cn/upload'`,上传成功取返回 URL(与 KSNewDialog 同款 on-change/on-remove 处理,失败提示重试),保存为英文逗号串;编辑时按逗号拆成 fileList 回填
+- publicAnnouncement:el-input type=textarea
+- scenicDescription:el-input type=textarea rows≈8(纯文本,不做富文本)
+- 编辑提交 = update(多带 id)
+
+### 6.3 自营分销商管理 `src/views/selfManage/thirdParty/index.vue` + 4 个子弹窗
+
+列表(POST /tenant/userThirdParty):
+
+- 搜索:名称(模糊)、联系人(精确)、电话(精确)、状态(1/0/全部);按钮:重置/搜索/新增/批量授权票种/批量删除
+- 表格列:名称 / 登录名(loginName) / 联系人 / 电话 / 余额(balance,两位小数) / 状态(tag 正常/禁用红色) / 已授权票种(selfTicketTypeList 数量 + 名称拼接,title 悬浮) / 最近登录(lastLoginTime) / 创建时间 / 备注
+- 行操作列:编辑、充值、扣款、票种授权、流水、删除
+- 顶部「批量授权票种」:表格 selection 多选分销商 → BindDialog(userThirdPartyIdList 多选)
+- 删除:前端先拦 `balance > 0`(提示先扣款);未完结订单前端不可见,靠后端报错弹 Message
+
+子弹窗:
+
+- **FormDialog**(新增/编辑)
+  - 新增:name 必填、loginName 必填、password 必填 + 前端校验(≥8 位且含大写、小写字母、数字三者中的字母大小写都有 + 数字,正则 `/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/`)、contactName、phone、remark;文案提示「新建余额为 0,创建后需单独授权票种」
+  - 编辑:name/loginName 只读展示;contactName/phone/remark 可改;password 选填(空 = 不改,填了同样校验);status(switch 正常/禁用,文案「禁用会立即踢下线」,切到禁用时二次确认)
+- **MoneyDialog**(充值/扣款共用,mode 参数区分)
+  - amount 必填 >0 最小 0.01;扣款时显示「当前余额 X」并前端拦超余额;提交 recharge/decrease
+- **BindDialog**(票种授权,支持单行/多选)
+  - 票种 el-select multiple(getSelfTicketTypeList 全量,label 用 name)
+  - 文案:「提交将覆盖所选分销商的原有授权;被授权票种需勾 third-party 渠道分销商端才可见」
+  - 允许清空:选 0 个票种也可提交(selfTicketTypeIdList: [] = 清空授权),用 confirm 二次确认
+- **BalanceLogDialog**(流水,只读)
+  - 打开时 userThirdPartyId 固定为该行分销商
+  - 搜索:type(1充值/2扣款/3消费/4退款/全部)、交易时间范围(datetime,转 yyyy-MM-dd HH:mm:ss)
+  - 表格:类型(tag) / 发生额(amount) / 变动前(beforeBalance) / 变动后(afterBalance) / 关联订单(orderId) / 操作人(operatorAdminId) / 交易时间(tradeTime);el-pagination
+
+### 6.4 销售日历 `src/views/selfManage/saleCalendar/index.vue`(独立菜单)
+
+- 顶部工具条:自营票种下拉(getSelfTicketTypeList 全量,显示 name;支持 `this.$route.query.id` 预选并自动加载)+ 提示文案「规则覆盖该票种某天价格/是否可售;C 端列表显示底价,详情/下单走日历」
+- 规则表(getSaleCalendarList,按 priority 升序展示):
+  - 列:优先级(priority,带 ↑↓ 两按钮调整顺序) / 价格(price) / 可售(isSale tag) / 规则内容(特定日期:specificDate 原文;周期:startDate~endDate + 周几中文名)/ 删除(单条,二次确认)
+- 新增规则(行内小表单或弹窗,二选一实现为弹窗 CalendarRuleDialog):
+  - price 必填 >0、isSale 必填(1/0)
+  - 类型 radio 二选一:
+    - 特定日期:可录入多个日期(el-date-picker type=date + 动态加行/或逗号分隔输入),提交拼 `specificDate` 逗号串
+    - 周期性:startDate/endDate 必填(date)+ weekdays el-checkbox-group 7 项(`1 周日…7 周六`,至少勾 1 个),提交拼逗号串
+  - 新规则追加到列表尾部(priority 按当前末尾 +1)
+- **保存(全量)**:把整表按当前顺序重排 priority = 1..n,`data` 以数组提交 saveSaleCalendar(每条含 selfTicketTypeId/price/isSale/priority + specificDate 或 startDate/endDate/weekdays;已有规则保留原 id)
+- **清空保护**:表内规则为 0 条时,「保存」按钮改为「恢复/清空」逻辑——若之前有规则且用户全部删掉,调用 deleteSaleCalendar(data.idList = 全部规则 id),**绝不用空数组调 /add**(后端取第 0 条会报错);无任何 id 可删时禁用保存
+- 未选票种时表格区显示空态提示
+
+## 7. 错误处理与交互约定
+
+- 全部走 `src/utils/request.js` 统一 Message 提示与 401/403 登出,页面内不重复弹错
+- 二次确认:所有删除/清空授权/禁用分销商用 `this.$confirm`
+- 分页:`{ pageNum: 1, pageSize: 10 }`,`res.data.records / res.data.total`,`el-pagination :current-page.sync` + `@current-change` 重载(与现有页一致)
+- 时间显示统一 `parseTime`(src/utils/index.js)
+- 金额展示统一两位小数
+
+## 8. 文件清单(新增/修改)
+
+新增:
+
+- src/api/selfManage.js
+- src/views/selfManage/ticketType/index.vue、src/views/selfManage/ticketType/FormDialog.vue
+- src/views/selfManage/scenic/index.vue、src/views/selfManage/scenic/FormDialog.vue
+- src/views/selfManage/thirdParty/index.vue、FormDialog.vue、MoneyDialog.vue、BindDialog.vue、BalanceLogDialog.vue
+- src/views/selfManage/saleCalendar/index.vue、CalendarRuleDialog.vue
+
+修改:
+
+- src/router/index.js(routerMap 插入自营管理块)
+- src/const.js(追加常量)
+
+不改:permission.js、request.js、login、layout、既有业务页。
+
+## 9. 验收标准
+
+1. `npm run dev` 启动后侧边栏出现「自营管理」(产品中心之后),展开有 4 个二级菜单;任意已登录账号可见
+2. 景点:新增(含 ≤6 张图上传、逗号串保存)→ 列表可见 → 编辑回填正确 → 删除(含已绑票种提示)
+3. 票种:选线下票种建自营票种(三类字段块均正确)→ 重复选同一线下票种被拦 → 编辑回填(含渠道/景点整表覆盖)→ 从列表跳销售日历带参
+4. 销售日历:选票种加载规则 → 增/删/调序 → 保存后 list 核对(priority 重排正确)→ 删空规则走 delete 不走 add
+5. 分销商:新增(密码复杂度校验)→ 充值/扣款(超余额拦截)→ 授权覆盖生效 → 流水只读可见 → 禁用踢下线确认 → 余额>0 删除被拦
+6. 所有接口失败时弹后端 msg,无白屏、无未捕获 Promise
+
+## 10. 风险与备注
+
+- 接口均为文档约定,后端如未部署 `/tenant/selfScenic` 等,页面表现为 Message 报错(开发期需先与后端确认环境已联调,见管理端文档联调顺序)
+- `el-upload` 返回结构沿用 KSNewDialog 的处理方式实现,联调时以实际 upload 响应字段为准
+- 菜单全员可见是临时决策(用户确认「先用 all」);后续上权限时只需给 4 个子路由补 permissionName 并在后端 resourceList 配 code