MembershipCenter.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. <template>
  2. <div class="membership-center admin-page">
  3. <!-- 统计卡片 -->
  4. <el-row :gutter="20" style="margin-bottom:20px;">
  5. <el-col :span="6">
  6. <el-card shadow="hover" class="stat-card">
  7. <div class="stat-value">{{ overview.freeCount }}</div>
  8. <div class="stat-label">免费用户</div>
  9. </el-card>
  10. </el-col>
  11. <el-col :span="6">
  12. <el-card shadow="hover" class="stat-card stat-family">
  13. <div class="stat-value">{{ overview.familyCount }}</div>
  14. <div class="stat-label">FAMILY会员</div>
  15. </el-card>
  16. </el-col>
  17. <el-col :span="6">
  18. <el-card shadow="hover" class="stat-card stat-provider">
  19. <div class="stat-value">{{ overview.providerCount }}</div>
  20. <div class="stat-label">PROVIDER会员</div>
  21. </el-card>
  22. </el-col>
  23. <el-col :span="6">
  24. <el-card shadow="hover" class="stat-card stat-new">
  25. <div class="stat-value">{{ overview.newFamilyThisMonth }}</div>
  26. <div class="stat-label">本月新增</div>
  27. </el-card>
  28. </el-col>
  29. <el-col :span="6">
  30. <el-card shadow="hover" class="stat-card stat-lifetime">
  31. <div class="stat-value">{{ overview.lifetimeCount }}</div>
  32. <div class="stat-label">终身会员</div>
  33. </el-card>
  34. </el-col>
  35. </el-row>
  36. <!-- 会员等级配置 -->
  37. <el-card style="margin-bottom:20px;">
  38. <div slot="header">
  39. <span>会员等级配置</span>
  40. </div>
  41. <el-table :max-height="tableHeight" :data="levels" v-loading="levelsLoading" border stripe>
  42. <el-table-column prop="levelCode" label="等级编码" width="120">
  43. <template slot-scope="{ row }">
  44. <el-tag v-if="row" :type="levelTagType(row.levelCode)" size="mini">{{ row.levelCode }}</el-tag>
  45. </template>
  46. </el-table-column>
  47. <el-table-column prop="levelName" label="等级名称" width="150" />
  48. <el-table-column prop="levelDesc" label="描述" min-width="180" show-overflow-tooltip />
  49. <el-table-column label="月费(元)" width="100">
  50. <template slot-scope="{ row }">{{ row ? formatPrice(row.priceMonthly) : '—' }}</template>
  51. </el-table-column>
  52. <el-table-column label="季费(元)" width="100">
  53. <template slot-scope="{ row }">{{ row ? formatPrice(row.priceQuarterly) : '—' }}</template>
  54. </el-table-column>
  55. <el-table-column label="年费(元)" width="100">
  56. <template slot-scope="{ row }">{{ row ? formatPrice(row.priceYearly) : '—' }}</template>
  57. </el-table-column>
  58. <el-table-column prop="maxChildren" label="孩子上限" width="90" />
  59. <el-table-column prop="maxTasksPerDay" label="每日任务" width="90" />
  60. <el-table-column label="AI审核" width="80">
  61. <template slot-scope="{ row }">
  62. <el-tag v-if="row" :type="row.aiReviewEnabled ? 'success' : 'info'" size="mini">{{ row.aiReviewEnabled ? '是' : '否' }}</el-tag>
  63. </template>
  64. </el-table-column>
  65. <el-table-column label="优先客服" width="80">
  66. <template slot-scope="{ row }">
  67. <el-tag v-if="row" :type="row.prioritySupport ? 'success' : 'info'" size="mini">{{ row.prioritySupport ? '是' : '否' }}</el-tag>
  68. </template>
  69. </el-table-column>
  70. <el-table-column label="操作" width="200" fixed="right">
  71. <template slot-scope="{ row }">
  72. <el-button v-if="row" size="mini" type="primary" @click="handleEditLevel(row)">编辑</el-button>
  73. <el-dropdown v-if="row" trigger="hover" @command="(cmd) => handleLevelCmd(row, cmd)">
  74. <el-button size="mini">
  75. 更多<i class="el-icon-arrow-down el-icon--right"></i>
  76. </el-button>
  77. <el-dropdown-menu slot="dropdown">
  78. <el-dropdown-item command="features" icon="el-icon-view">查看权限</el-dropdown-item>
  79. </el-dropdown-menu>
  80. </el-dropdown>
  81. </template>
  82. </el-table-column>
  83. </el-table>
  84. </el-card>
  85. <!-- 升级记录 -->
  86. <el-card>
  87. <div slot="header">
  88. <span>会员升级记录</span>
  89. </div>
  90. <el-table :data="upgradeRecords" v-loading="recordsLoading" border stripe :max-height="tableHeight">
  91. <el-table-column prop="id" label="ID" width="70" />
  92. <el-table-column label="用户" width="120">
  93. <template slot-scope="{ row }">{{ row ? (row.userName || '用户' + row.userId) : '—' }}</template>
  94. </el-table-column>
  95. <el-table-column label="从" width="100">
  96. <template slot-scope="{ row }">
  97. <el-tag v-if="row" size="mini">{{ row.fromLevel || 'FREE' }}</el-tag>
  98. </template>
  99. </el-table-column>
  100. <el-table-column label="到" width="100">
  101. <template slot-scope="{ row }">
  102. <el-tag v-if="row" type="success" size="mini">{{ row.toLevel }}</el-tag>
  103. </template>
  104. </el-table-column>
  105. <el-table-column prop="upgradeType" label="升级方式" width="120">
  106. <template slot-scope="{ row }">
  107. <el-tag v-if="row" :type="upgradeTypeTag(row.upgradeType)" size="mini">{{ upgradeTypeLabel(row.upgradeType) }}</el-tag>
  108. </template>
  109. </el-table-column>
  110. <el-table-column prop="orderId" label="订单ID" width="80" />
  111. <el-table-column prop="expireTime" label="到期时间" width="160">
  112. <template slot-scope="{ row }">{{ row ? formatTime(row.expireTime) : '—' }}</template>
  113. </el-table-column>
  114. <el-table-column prop="createdAt" label="创建时间" width="160">
  115. <template slot-scope="{ row }">{{ row ? formatTime(row.createdAt) : '—' }}</template>
  116. </el-table-column>
  117. </el-table>
  118. <el-pagination
  119. v-if="recordsTotal > 0"
  120. @size-change="handleRecordSizeChange"
  121. @current-change="handleRecordPageChange"
  122. :current-page="recordFilter.page"
  123. :page-sizes="[10, 20, 50]"
  124. :page-size="recordFilter.size"
  125. layout="total, sizes, prev, pager, next, jumper"
  126. :total="recordsTotal"
  127. style="margin-top:16px;text-align:right"
  128. />
  129. </el-card>
  130. <!-- 会员维护 -->
  131. <el-card style="margin-top:20px;">
  132. <div slot="header">
  133. <span>会员维护</span>
  134. </div>
  135. <div class="maintain-row">
  136. <el-input
  137. v-model="maintainSearch"
  138. placeholder="输入手机号/昵称/用户ID搜索"
  139. clearable
  140. style="width:280px;margin-right:10px;"
  141. @keyup.enter.native="handleMaintainSearch"
  142. />
  143. <el-button type="primary" size="small" @click="handleMaintainSearch">搜索</el-button>
  144. </div>
  145. <div v-if="maintainUsers.length" style="margin-top:14px;">
  146. <el-table :data="maintainUsers" border stripe size="small">
  147. <el-table-column prop="id" label="ID" width="70" />
  148. <el-table-column prop="nickname" label="昵称" width="140" />
  149. <el-table-column prop="phone" label="手机号" width="140" />
  150. <el-table-column label="当前等级" width="110">
  151. <template slot-scope="{ row }">
  152. <el-tag v-if="row" :type="levelTagType(row.memberLevel)" size="mini">{{ row.memberLevel || 'FREE' }}</el-tag>
  153. </template>
  154. </el-table-column>
  155. <el-table-column label="推荐人ID" width="100">
  156. <template slot-scope="{ row }">{{ row ? (row.referrerId || '—') : '—' }}</template>
  157. </el-table-column>
  158. <el-table-column label="操作" width="160">
  159. <template slot-scope="{ row }">
  160. <el-button v-if="row" size="mini" type="warning" @click="openMaintainDialog(row)">维护</el-button>
  161. </template>
  162. </el-table-column>
  163. </el-table>
  164. </div>
  165. </el-card>
  166. <!-- 会员维护对话框 -->
  167. <el-dialog title="会员维护" :visible.sync="maintainVisible" width="520px" @close="maintainVisible = false">
  168. <el-form :model="maintainForm" label-width="100px" size="small">
  169. <el-form-item label="用户">
  170. <span>{{ maintainForm.nickname }}(ID: {{ maintainForm.id }})</span>
  171. </el-form-item>
  172. <el-form-item label="会员等级">
  173. <el-select v-model="maintainForm.memberLevel" style="width:200px;" placeholder="选择等级">
  174. <el-option
  175. v-for="lv in safeLevels"
  176. :key="lv.levelCode"
  177. :label="lv.levelName + ' (' + lv.levelCode + ')'"
  178. :value="lv.levelCode"
  179. />
  180. </el-select>
  181. <span style="color:#999;margin-left:8px;">选终身会员则永久有效</span>
  182. </el-form-item>
  183. <el-form-item label="推荐人ID">
  184. <el-input v-model="maintainForm.referrerIdStr" placeholder="留空不变,填0清除" style="width:200px;" />
  185. <span style="color:#999;margin-left:8px;">填目标用户ID</span>
  186. </el-form-item>
  187. </el-form>
  188. <div slot="footer">
  189. <el-button @click="maintainVisible = false">取消</el-button>
  190. <el-button type="primary" :loading="maintainSubmitting" @click="handleMaintainSubmit">保存</el-button>
  191. </div>
  192. </el-dialog>
  193. <!-- 权限详情对话框 -->
  194. <el-dialog title="功能权限详情" :visible.sync="featuresVisible" width="500px">
  195. <div v-if="currentLevel" class="features-content">
  196. <h3>{{ currentLevel.levelName }} ({{ currentLevel.levelCode }})</h3>
  197. <p style="color:#666;margin-bottom:16px;">{{ currentLevel.levelDesc }}</p>
  198. <el-descriptions :column="1" border>
  199. <el-descriptions-item label="孩子数量上限">{{ currentLevel.maxChildren }}</el-descriptions-item>
  200. <el-descriptions-item label="每日任务上限">{{ currentLevel.maxTasksPerDay }}</el-descriptions-item>
  201. <el-descriptions-item label="AI审核">{{ currentLevel.aiReviewEnabled ? '启用' : '未启用' }}</el-descriptions-item>
  202. <el-descriptions-item label="优先客服">{{ currentLevel.prioritySupport ? '支持' : '不支持' }}</el-descriptions-item>
  203. <el-descriptions-item label="老师咨询">{{ currentLevel.teacherConsultation ? '支持' : '不支持' }}</el-descriptions-item>
  204. </el-descriptions>
  205. <div v-if="currentLevel.features" style="margin-top:16px;">
  206. <div class="features-title">功能权限列表</div>
  207. <div class="features-list">
  208. <el-tag v-for="(f, i) in parseFeatures(currentLevel.features)" :key="i" size="mini" style="margin:4px;">{{ f }}</el-tag>
  209. <span v-if="!parseFeatures(currentLevel.features).length" style="color:#999;">暂无特殊权限</span>
  210. </div>
  211. </div>
  212. </div>
  213. </el-dialog>
  214. <!-- 编辑会员等级 -->
  215. <el-dialog title="编辑会员等级" :visible.sync="editVisible" width="540px" @close="editVisible = false">
  216. <el-form :model="editForm" label-width="110px" size="small">
  217. <el-form-item label="等级编码">
  218. <el-tag>{{ editForm.levelCode }}</el-tag>
  219. <span style="color:#999;margin-left:8px;">编码不可修改</span>
  220. </el-form-item>
  221. <el-form-item label="等级名称" required>
  222. <el-input v-model="editForm.levelName" placeholder="如:家庭会员" maxlength="32" show-word-limit />
  223. </el-form-item>
  224. <el-form-item label="等级描述">
  225. <el-input v-model="editForm.levelDesc" type="textarea" :rows="2" maxlength="200" show-word-limit />
  226. </el-form-item>
  227. <el-form-item label="月费(元)">
  228. <el-input-number v-model="editForm.priceMonthly" :min="0" :step="100" :precision="0" style="width:200px" />
  229. <span style="color:#999;margin-left:8px;">单位:分</span>
  230. </el-form-item>
  231. <el-form-item label="季费(元)">
  232. <el-input-number v-model="editForm.priceQuarterly" :min="0" :step="100" :precision="0" style="width:200px" />
  233. </el-form-item>
  234. <el-form-item label="年费(元)">
  235. <el-input-number v-model="editForm.priceYearly" :min="0" :step="100" :precision="0" style="width:200px" />
  236. </el-form-item>
  237. <el-form-item label="孩子上限">
  238. <el-input-number v-model="editForm.maxChildren" :min="0" :max="20" :step="1" style="width:200px" />
  239. </el-form-item>
  240. <el-form-item label="每日任务上限">
  241. <el-input-number v-model="editForm.maxTasksPerDay" :min="0" :max="999" :step="1" style="width:200px" />
  242. </el-form-item>
  243. <el-form-item label="AI审核">
  244. <el-switch v-model="editForm.aiReviewEnabled" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="关闭" />
  245. </el-form-item>
  246. <el-form-item label="优先客服">
  247. <el-switch v-model="editForm.prioritySupport" :active-value="1" :inactive-value="0" active-text="支持" inactive-text="不支持" />
  248. </el-form-item>
  249. </el-form>
  250. <div slot="footer">
  251. <el-button @click="editVisible = false">取消</el-button>
  252. <el-button type="primary" :loading="submittingLevel" @click="handleSubmitLevel">保存</el-button>
  253. </div>
  254. </el-dialog>
  255. </div>
  256. </template>
  257. <script>
  258. import { searchUsers, updateMembershipUser } from '@/api/admin'
  259. export default {
  260. name: 'MembershipCenter',
  261. computed: {
  262. tableHeight() {
  263. return window.innerHeight - 300
  264. },
  265. safeLevels() {
  266. const list = this.levels || []
  267. return list.filter(function(l) { return l && typeof l === 'object' && l.levelCode })
  268. }
  269. },
  270. data() {
  271. return {
  272. // overview
  273. overview: {
  274. freeCount: 0,
  275. familyCount: 0,
  276. providerCount: 0,
  277. newFamilyThisMonth: 0,
  278. totalFamilyMemberships: 0,
  279. lifetimeCount: 0
  280. },
  281. // levels
  282. levelsLoading: false,
  283. levels: [],
  284. // upgrade records
  285. recordsLoading: false,
  286. upgradeRecords: [],
  287. recordsTotal: 0,
  288. recordFilter: {
  289. page: 1,
  290. size: 10
  291. },
  292. // dialogs
  293. featuresVisible: false,
  294. currentLevel: null,
  295. // membership maintain
  296. maintainSearch: '',
  297. maintainUsers: [],
  298. maintainVisible: false,
  299. maintainSubmitting: false,
  300. maintainForm: {
  301. id: null,
  302. nickname: '',
  303. memberLevel: '',
  304. referrerIdStr: ''
  305. }
  306. }
  307. },
  308. created() {
  309. this.loadOverview()
  310. this.loadLevels()
  311. this.loadUpgradeRecords()
  312. },
  313. methods: {
  314. formatPrice(price) {
  315. if (price === null || price === undefined) return '—'
  316. return (price / 100).toFixed(2)
  317. },
  318. formatTime(t) {
  319. if (!t) return '-'
  320. return t.replace ? t.replace('T', ' ').substring(0, 19) : t
  321. },
  322. levelTagType(code) {
  323. if (code === 'FREE') return 'info'
  324. if (code === 'FAMILY') return 'success'
  325. if (code === 'PROVIDER') return 'warning'
  326. if (code === 'LIFETIME') return 'danger' // 紫红色高亮终身会员
  327. return 'primary'
  328. },
  329. upgradeTypeLabel(type) {
  330. const map = { pay: '付费升级', consumption: '消费升级', admin: '管理员操作', trial: '试用激活', lifetime: '终身会员授予' }
  331. return map[type] || type || '—'
  332. },
  333. upgradeTypeTag(type) {
  334. if (type === 'pay') return 'success'
  335. if (type === 'consumption') return 'primary'
  336. if (type === 'admin') return 'warning'
  337. if (type === 'trial') return 'info'
  338. if (type === 'lifetime') return 'danger'
  339. return ''
  340. },
  341. parseFeatures(features) {
  342. if (!features) return []
  343. try {
  344. if (features.startsWith('[')) return JSON.parse(features)
  345. return features.split(',').map(f => f.trim()).filter(Boolean)
  346. } catch (e) {
  347. return []
  348. }
  349. },
  350. async _request(url, data) {
  351. const axios = this.$axios || (await import('axios')).default
  352. const baseURL = process.env.VUE_APP_BASE_API || ''
  353. return axios.post(baseURL + url, data)
  354. },
  355. async loadOverview() {
  356. try {
  357. const res = await this._request('/api/admin/membership/overview')
  358. if (res.data && res.data.code === 200) {
  359. Object.assign(this.overview, res.data.data)
  360. }
  361. } catch (e) {
  362. // ignore
  363. }
  364. },
  365. async loadLevels() {
  366. this.levelsLoading = true
  367. try {
  368. const res = await this._request('/api/admin/membership/levels')
  369. if (res.data && res.data.code === 200) {
  370. const list = res.data.data || []
  371. this.levels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' }) : []
  372. }
  373. } catch (e) {
  374. this.$message && this.$message.error && this.$message.error('加载等级配置失败')
  375. } finally {
  376. this.levelsLoading = false
  377. }
  378. },
  379. async loadUpgradeRecords() {
  380. this.recordsLoading = true
  381. try {
  382. const res = await this._request('/api/admin/membership/upgrade-records', {
  383. page: this.recordFilter.page,
  384. size: this.recordFilter.size
  385. })
  386. if (res.data && res.data.code === 200) {
  387. const records = (res.data.data && res.data.data.records) || []
  388. this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
  389. this.recordsTotal = res.data.data.total || 0
  390. await this.resolveUserNames(this.upgradeRecords, 'userId')
  391. }
  392. } catch (e) {
  393. this.$message && this.$message.error && this.$message.error('加载升级记录失败')
  394. } finally {
  395. this.recordsLoading = false
  396. }
  397. },
  398. handleRecordSizeChange(size) {
  399. this.recordFilter.size = size
  400. this.recordFilter.page = 1
  401. this.loadUpgradeRecords()
  402. },
  403. handleRecordPageChange(page) {
  404. this.recordFilter.page = page
  405. this.loadUpgradeRecords()
  406. },
  407. handleEditLevel(row) {
  408. this.$message.info('会员等级配置请在 "系统配置" 中编辑')
  409. },
  410. handleLevelCmd(row, cmd) {
  411. switch (cmd) {
  412. case 'features':
  413. this.currentLevel = row
  414. this.featuresVisible = true
  415. break
  416. }
  417. },
  418. resolveUserNames(items, idField) {
  419. if (!items || items.length === 0) return Promise.resolve()
  420. var ids = []
  421. items.forEach(function(item) {
  422. if (item[idField] && ids.indexOf(item[idField]) === -1) ids.push(item[idField])
  423. })
  424. var nameMap = {}
  425. return Promise.all(ids.map(function(id) {
  426. return searchUsers({ keyword: String(id) })
  427. .then(function(res) {
  428. if (res.data && res.data.length > 0) {
  429. var u = res.data[0]
  430. nameMap[id] = u.nickname || u.realName || '用户' + id
  431. } else {
  432. nameMap[id] = '用户' + id
  433. }
  434. })
  435. .catch(function() {
  436. nameMap[id] = '用户' + id
  437. })
  438. })).then(function() {
  439. items.forEach(function(item) {
  440. item.userName = nameMap[item[idField]] || '用户' + item[idField]
  441. })
  442. })
  443. },
  444. async handleMaintainSearch() {
  445. const kw = (this.maintainSearch || '').trim()
  446. if (!kw) {
  447. this.$message && this.$message.warning && this.$message.warning('请输入搜索关键词')
  448. return
  449. }
  450. try {
  451. const res = await searchUsers({ keyword: kw })
  452. if (res.data && res.data.length > 0) {
  453. this.maintainUsers = res.data
  454. } else {
  455. this.maintainUsers = []
  456. this.$message && this.$message.info && this.$message.info('未找到用户')
  457. }
  458. } catch (e) {
  459. this.$message && this.$message.error && this.$message.error('搜索失败')
  460. }
  461. },
  462. openMaintainDialog(row) {
  463. this.maintainForm = {
  464. id: row.id,
  465. nickname: row.nickname || row.realName || ('用户' + row.id),
  466. memberLevel: row.memberLevel || 'FREE',
  467. referrerIdStr: row.referrerId ? String(row.referrerId) : ''
  468. }
  469. this.maintainVisible = true
  470. },
  471. async handleMaintainSubmit() {
  472. if (!this.maintainForm.id) return
  473. this.maintainSubmitting = true
  474. try {
  475. const payload = { userId: this.maintainForm.id }
  476. if (this.maintainForm.memberLevel) {
  477. payload.memberLevel = this.maintainForm.memberLevel
  478. }
  479. const refStr = (this.maintainForm.referrerIdStr || '').trim()
  480. if (refStr === '0') {
  481. payload.referrerId = ''
  482. } else if (refStr !== '') {
  483. payload.referrerId = refStr
  484. }
  485. const res = await updateMembershipUser(payload)
  486. if (res.code === 200) {
  487. this.$message && this.$message.success && this.$message.success(res.data || '更新成功')
  488. this.maintainVisible = false
  489. this.handleMaintainSearch()
  490. this.loadOverview()
  491. this.loadUpgradeRecords()
  492. } else {
  493. this.$message && this.$message.error && this.$message.error(res.message || '更新失败')
  494. }
  495. } catch (e) {
  496. this.$message && this.$message.error && this.$message.error('更新失败')
  497. } finally {
  498. this.maintainSubmitting = false
  499. }
  500. }
  501. }
  502. }
  503. </script>
  504. <style scoped>
  505. .membership-center { padding: 20px; }
  506. .stat-card { text-align: center; cursor: default; }
  507. .stat-card .stat-value { font-size: 32px; font-weight: bold; color: #303133; }
  508. .stat-card .stat-label { font-size: 14px; color: #909399; margin-top: 8px; }
  509. .stat-family .stat-value { color: #67C23A; }
  510. .stat-provider .stat-value { color: #E6A23C; }
  511. .stat-new .stat-value { color: #409EFF; }
  512. .stat-lifetime .stat-value { color: #F56C6C; }
  513. .features-content { padding: 10px 0; }
  514. .features-title { font-weight: bold; margin-bottom: 8px; color: #606266; }
  515. .features-list { display: flex; flex-wrap: wrap; }
  516. </style>