level.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <template>
  2. <view class="app-content level-page">
  3. <view class="page-header">
  4. <view class="page-title">会员等级设置</view>
  5. <view class="page-subtitle">充值或消费1元等于1个成长值,余额消费没有成长值</view>
  6. </view>
  7. <view class="main-content">
  8. <view class="table-head">
  9. <view class="col-icon">图标</view>
  10. <view class="col-level">等级</view>
  11. <view class="col-amount">成长值</view>
  12. <view class="col-discount">折扣</view>
  13. </view>
  14. <view class="level-card" v-for="(item, index) in map" :key="index">
  15. <view class="card-top">
  16. <view class="member-icon col-icon">
  17. <image v-if="item.member == 1" class="member-badge-icon" src="@/static/member-icons/member-1.svg" mode="aspectFit" />
  18. <image v-else-if="item.member == 2" class="member-badge-icon" src="@/static/member-icons/member-2.svg" mode="aspectFit" />
  19. <image v-else-if="item.member == 3" class="member-badge-icon" src="@/static/member-icons/member-3.svg" mode="aspectFit" />
  20. <image v-else-if="item.member == 4" class="member-badge-icon" src="@/static/member-icons/member-4.svg" mode="aspectFit" />
  21. <image v-else-if="item.member == 5" class="member-badge-icon" src="@/static/member-icons/member-5.svg" mode="aspectFit" />
  22. </view>
  23. <view class="col-level">
  24. <view class="level-name">{{ item.name }}</view>
  25. </view>
  26. <view class="field-inline col-amount">
  27. <input v-model="item.amount" type="digit" class="field-inp" placeholder="金额" @focus="() => clearDiscount(index)" />
  28. </view>
  29. <view class="field-inline col-discount">
  30. <view
  31. v-if="!item.discountEnabled"
  32. class="discount-toggle off"
  33. @click="enableDiscount(index)"
  34. >
  35. <text class="discount-off-text">关闭</text>
  36. </view>
  37. <view v-else class="discount-toggle on">
  38. <input
  39. v-model="item.discount"
  40. type="digit"
  41. class="discount-inp"
  42. placeholder="8.8"
  43. placeholder-style="color:#cccccc"
  44. :focus="focusDiscountIndex === index"
  45. @blur="onDiscountBlur"
  46. />
  47. <text class="discount-unit">折</text>
  48. <view class="discount-close" @click="disableDiscount(index)">
  49. <text class="discount-close-icon">×</text>
  50. </view>
  51. </view>
  52. </view>
  53. </view>
  54. <view class="card-section">
  55. <view class="section-label">生日权益</view>
  56. <input
  57. v-model="item.birthdayBenefit"
  58. class="section-inp"
  59. maxlength="50"
  60. placeholder="请输入生日权益内容"
  61. />
  62. <view class="char-count">{{ (item.birthdayBenefit || '').length }}/10</view>
  63. </view>
  64. <view class="card-section">
  65. <view class="section-label">权益说明</view>
  66. <textarea
  67. v-model="item.benefitDesc"
  68. class="section-textarea"
  69. maxlength="500"
  70. placeholder="请输入会员权益说明内容"
  71. />
  72. <view class="char-count">{{ (item.benefitDesc || '').length }}/500</view>
  73. </view>
  74. </view>
  75. </view>
  76. <view class="app-footer">
  77. <view class="footer-btn admin-button-com big default" style="border:1upx solid #CCCCCC;" @click="goBack()">返回上页</view>
  78. <view class="footer-btn admin-button-com big blue" @click="saveData">提交修改</view>
  79. </view>
  80. </view>
  81. </template>
  82. <script>
  83. import list from '@/mixins/list';
  84. import { getLevelInitData, modifyLevel } from '@/api/member';
  85. export default {
  86. name: "MemberLevel",
  87. mixins: [list],
  88. data() {
  89. return {
  90. map: [],
  91. reduceDay: 30,
  92. // 开启折扣后要聚焦的输入框下标,-1 表示不聚焦
  93. focusDiscountIndex: -1
  94. };
  95. },
  96. methods: {
  97. init() {
  98. getLevelInitData().then(res => {
  99. if (res.code === 1) {
  100. this.map = res.data.member || [];
  101. this.map.forEach((item, index) => {
  102. if (!item.member) {
  103. item.member = index + 1;
  104. }
  105. if (item.discount === undefined || item.discount === null || item.discount === '') {
  106. item.discount = 10;
  107. }
  108. this.$set(item, 'discountEnabled', Number(item.discount) < 10);
  109. if (!item.birthdayBenefit) {
  110. item.birthdayBenefit = '';
  111. }
  112. if (!item.benefitDesc) {
  113. item.benefitDesc = '';
  114. }
  115. });
  116. this.reduceDay = res.data.reduceDay || 30;
  117. }
  118. }).catch(err => {
  119. console.error('获取会员等级数据失败:', err);
  120. this.$msg('获取数据失败,请重试');
  121. });
  122. },
  123. clearDiscount(index) {
  124. this.$set(this.map[index], 'amount', '');
  125. },
  126. enableDiscount(index) {
  127. this.$set(this.map[index], 'discountEnabled', true);
  128. if (!this.map[index].discount || Number(this.map[index].discount) >= 10) {
  129. this.$set(this.map[index], 'discount', '');
  130. }
  131. // 先重置再 nextTick 设焦点,兼容小程序 :focus 需 false→true 切换
  132. this.focusDiscountIndex = -1;
  133. this.$nextTick(() => {
  134. this.focusDiscountIndex = index;
  135. });
  136. },
  137. disableDiscount(index) {
  138. this.$set(this.map[index], 'discountEnabled', false);
  139. this.$set(this.map[index], 'discount', 10);
  140. this.focusDiscountIndex = -1;
  141. },
  142. onDiscountBlur() {
  143. this.focusDiscountIndex = -1;
  144. },
  145. // 取提交/校验用的有效折扣:未开启视为 10 折(无优惠)
  146. getEffectiveDiscount(item) {
  147. if (!item.discountEnabled) {
  148. return 10;
  149. }
  150. return parseFloat(item.discount);
  151. },
  152. getSubmitData() {
  153. return this.map.map(item => {
  154. const row = Object.assign({}, item);
  155. if (!row.discountEnabled) {
  156. row.discount = 10;
  157. }
  158. delete row.discountEnabled;
  159. return row;
  160. });
  161. },
  162. goBack() {
  163. uni.navigateBack();
  164. },
  165. saveData() {
  166. if (!this.validateData()) {
  167. return;
  168. }
  169. this.$util.confirmModal({ content: '确认提交?' }, () => {
  170. modifyLevel({
  171. data: this.getSubmitData(),
  172. reduceDay: this.reduceDay
  173. }).then(res => {
  174. if (res.code === 1) {
  175. this.$msg(res.msg);
  176. setTimeout(() => uni.navigateBack(), 900);
  177. }
  178. });
  179. });
  180. },
  181. validateData() {
  182. const hasEmptyAmount = this.map.some(item => !item.amount || item.amount === '');
  183. if (hasEmptyAmount) {
  184. this.$msg('请填写完整的消费金额');
  185. return false;
  186. }
  187. const hasInvalidAmount = this.map.some(item => {
  188. const amount = parseFloat(item.amount);
  189. return isNaN(amount) || amount < 0;
  190. });
  191. if (hasInvalidAmount) {
  192. this.$msg('请输入有效的消费金额');
  193. return false;
  194. }
  195. const hasInvalidDiscount = this.map.some(item => {
  196. if (!item.discountEnabled) {
  197. return false;
  198. }
  199. const discount = parseFloat(item.discount);
  200. return item.discount === '' || item.discount === undefined || item.discount === null || isNaN(discount) || discount <= 0 || discount >= 10;
  201. });
  202. if (hasInvalidDiscount) {
  203. this.$msg('开启折扣后请输入小于10的有效折扣');
  204. return false;
  205. }
  206. // 等级越高,折扣数值须 ≤ 上一等级(如上一级 9.5 折,本级最高只能 9.5 折)
  207. for (let i = 1; i < this.map.length; i++) {
  208. const prevItem = this.map[i - 1];
  209. const currItem = this.map[i];
  210. const prevDiscount = this.getEffectiveDiscount(prevItem);
  211. const currDiscount = this.getEffectiveDiscount(currItem);
  212. if (currDiscount > prevDiscount) {
  213. const prevLabel = prevItem.discountEnabled ? `${prevDiscount}折` : '无折扣';
  214. this.$msg(`${currItem.name}的折扣不能高于${prevItem.name}(${prevLabel})`);
  215. return false;
  216. }
  217. }
  218. const hasLongBenefit = this.map.some(item => (item.birthdayBenefit || '').length > 50);
  219. if (hasLongBenefit) {
  220. this.$msg('生日权益不超过50字');
  221. return false;
  222. }
  223. const hasLongDesc = this.map.some(item => (item.benefitDesc || '').length > 500);
  224. if (hasLongDesc) {
  225. this.$msg('会员权益说明不超过500字');
  226. return false;
  227. }
  228. return true;
  229. }
  230. }
  231. };
  232. </script>
  233. <style lang="scss" scoped>
  234. .level-page {
  235. position: relative;
  236. background: linear-gradient(180deg, #eff9ef 0%, #f8fcf8 44%, #f7fbf7 100%);
  237. min-height: 100vh;
  238. padding: 0 16upx 160upx;
  239. box-sizing: border-box;
  240. overflow: hidden;
  241. }
  242. .page-header {
  243. position: relative;
  244. z-index: 1;
  245. padding: 44upx 30upx 28upx;
  246. .page-title {
  247. font-size: 42upx;
  248. font-weight: 700;
  249. color: #1f6f40;
  250. text-align: center;
  251. letter-spacing: 2upx;
  252. }
  253. .page-subtitle {
  254. font-size: 28upx;
  255. color: #a5aaa8;
  256. text-align: center;
  257. margin-top: 12upx;
  258. }
  259. .title-decor {
  260. width: 10upx;
  261. height: 10upx;
  262. margin: 18upx auto 0;
  263. position: relative;
  264. background: #4ec56a;
  265. transform: rotate(45deg);
  266. }
  267. .title-decor::before,
  268. .title-decor::after {
  269. content: '';
  270. position: absolute;
  271. top: 4upx;
  272. width: 28upx;
  273. height: 1upx;
  274. background: #d8ead6;
  275. transform: rotate(-45deg);
  276. }
  277. .title-decor::before {
  278. right: 24upx;
  279. }
  280. .title-decor::after {
  281. left: 24upx;
  282. }
  283. }
  284. .main-content {
  285. position: relative;
  286. z-index: 1;
  287. background: #fff;
  288. border-radius: 18upx;
  289. padding: 0 18upx 2upx;
  290. box-shadow: 0 8upx 28upx rgba(35, 121, 63, 0.08);
  291. }
  292. .table-head {
  293. display: flex;
  294. align-items: center;
  295. height: 74upx;
  296. font-size: 28upx;
  297. font-weight: 600;
  298. color: #28884d;
  299. border-bottom: 1upx solid #edf3ed;
  300. box-sizing: border-box;
  301. }
  302. .table-head .col-icon,
  303. .table-head .col-level,
  304. .table-head .col-amount,
  305. .table-head .col-discount {
  306. text-align: center;
  307. }
  308. .level-card {
  309. padding: 22upx 0 24upx;
  310. border-bottom: 1upx solid #f1f4f0;
  311. }
  312. .level-card:last-child {
  313. border-bottom: 0;
  314. }
  315. .card-top {
  316. display: flex;
  317. align-items: center;
  318. margin-bottom: 18upx;
  319. }
  320. .col-icon {
  321. width: 112upx;
  322. flex-shrink: 0;
  323. text-align: center;
  324. }
  325. .col-level {
  326. width: 160upx;
  327. flex-shrink: 0;
  328. }
  329. .col-amount {
  330. flex: 1;
  331. margin-left: 6upx;
  332. }
  333. .col-discount {
  334. width: 180upx;
  335. flex-shrink: 0;
  336. margin-left: 18upx;
  337. }
  338. .member-icon {
  339. display: flex;
  340. align-items: center;
  341. justify-content: center;
  342. }
  343. .member-badge-icon {
  344. width: 82upx;
  345. height: 82upx;
  346. }
  347. .level-name {
  348. display: flex;
  349. align-items: center;
  350. justify-content: center;
  351. width: 152upx;
  352. height: 56upx;
  353. background: linear-gradient(90deg, #67d85a, #41b94e);
  354. color: #fff;
  355. padding: 0 14upx;
  356. border-radius: 20upx;
  357. font-size: 28upx;
  358. font-weight: 600;
  359. box-shadow: 0 4upx 10upx rgba(67, 183, 78, 0.2);
  360. box-sizing: border-box;
  361. }
  362. .field-inline {
  363. display: flex;
  364. align-items: center;
  365. }
  366. .field-inp {
  367. width: 100%;
  368. height: 54upx;
  369. line-height: 54upx;
  370. border: 1upx solid #edf0ed;
  371. border-radius: 8upx;
  372. padding: 0 8upx;
  373. font-size: 32upx;
  374. font-weight: 600;
  375. color: #368753;
  376. text-align: center;
  377. box-sizing: border-box;
  378. background: #fff;
  379. }
  380. .discount-toggle {
  381. width: 100%;
  382. height: 54upx;
  383. display: flex;
  384. align-items: center;
  385. justify-content: center;
  386. border-radius: 27upx;
  387. box-sizing: border-box;
  388. padding: 0 6upx 0 12upx;
  389. }
  390. .discount-toggle.off {
  391. justify-content: center;
  392. padding: 0;
  393. background: #f1f3f1;
  394. border: 1upx solid #e3e7e3;
  395. }
  396. .discount-off-text {
  397. font-size: 24upx;
  398. color: #9ca3a0;
  399. }
  400. .discount-toggle.on {
  401. background: #fff;
  402. border: 1upx solid #41b94e;
  403. }
  404. .discount-inp {
  405. flex: 1;
  406. min-width: 0;
  407. height: 100%;
  408. line-height: 54upx;
  409. font-size: 30upx;
  410. font-weight: 600;
  411. color: #368753;
  412. text-align: center;
  413. }
  414. .discount-unit {
  415. flex-shrink: 0;
  416. font-size: 28upx;
  417. color: #368753;
  418. margin-left: 2upx;
  419. }
  420. .discount-close {
  421. flex-shrink: 0;
  422. width: 44upx;
  423. height: 44upx;
  424. display: flex;
  425. align-items: center;
  426. justify-content: center;
  427. margin-left: 2upx;
  428. }
  429. .discount-close-icon {
  430. font-size: 44upx;
  431. color: #b7c0b7;
  432. line-height: 1;
  433. transform: translateY(-4upx);
  434. }
  435. .card-section {
  436. position: relative;
  437. display: flex;
  438. align-items: flex-start;
  439. margin-top: 18upx;
  440. }
  441. .section-label {
  442. width: 132upx;
  443. flex-shrink: 0;
  444. line-height: 58upx;
  445. font-size: 28upx;
  446. font-weight: 600;
  447. color: #3b3f3c;
  448. }
  449. .section-inp {
  450. flex: 1;
  451. height: 62upx;
  452. line-height: 62upx;
  453. border: 1upx solid #edf0ed;
  454. border-radius: 8upx;
  455. padding: 0 18upx;
  456. font-size: 28upx;
  457. box-sizing: border-box;
  458. color: #333;
  459. }
  460. .section-textarea {
  461. flex: 1;
  462. height: 140upx;
  463. min-height: 130upx;
  464. border: 1upx solid #edf0ed;
  465. border-radius: 8upx;
  466. padding: 14upx 18upx 28upx;
  467. font-size: 28upx;
  468. line-height: 38upx;
  469. box-sizing: border-box;
  470. color: #333;
  471. }
  472. .char-count {
  473. position: absolute;
  474. right: 16upx;
  475. bottom: 8upx;
  476. font-size: 26upx;
  477. color: #a8a8a8;
  478. line-height: 1;
  479. }
  480. .app-footer {
  481. position: fixed;
  482. left: 0;
  483. right: 0;
  484. bottom: 0;
  485. display: flex;
  486. padding: 20upx 24upx;
  487. background: #fff;
  488. box-shadow: 0 -4upx 12upx rgba(0, 0, 0, 0.06);
  489. .footer-btn {
  490. flex: 1;
  491. margin: 0 10upx;
  492. }
  493. }
  494. </style>