index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <template>
  2. <div class="container">
  3. <Breadcrumb :items="['校园乐跑', '账号管理']" />
  4. <userCard />
  5. <a-card title="账号列表" style="margin-top: 15px;">
  6. <a-button type="primary" size="large" @click="editAccount">
  7. <template #icon>
  8. <icon-plus />
  9. </template>
  10. 添加账号
  11. </a-button>
  12. <a-button size="large" @click="download" style="margin-left: 10px;">
  13. <template #icon>
  14. <icon-question-circle />
  15. </template>
  16. 操作说明
  17. </a-button>
  18. <a-button size="large" @click="$router.push('/download/down')" style="margin-left: 10px;" v-if="!isElectron()">
  19. <template #icon>
  20. <icon-download />
  21. </template>
  22. 客户端/登录器下载
  23. </a-button>
  24. <a-table :data="data" stripe hoverable column-resizable class="table" :loading="loading" :scroll="{
  25. x: 1600
  26. }" :pagination="{ showPageSize: true, showJumper: true, defaultPageSize: 15 }">
  27. <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
  28. <div class="custom-filter">
  29. <a-space direction="vertical">
  30. <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
  31. <div class="custom-filter-footer">
  32. <a-button @click="handleFilterReset">重置</a-button>
  33. <a-button @click="handleFilterConfirm">确定</a-button>
  34. </div>
  35. </a-space>
  36. </div>
  37. </template>
  38. <template #columns>
  39. <a-table-column title="" :width="60" data-index="user_avatar" tooltip>
  40. <template #cell="{ record }">
  41. <a-avatar>
  42. <IconUser v-if="!record.user_avatar" />
  43. <img :alt="record.name ?? ''" :src="record.user_avatar" v-else />
  44. </a-avatar>
  45. </template>
  46. </a-table-column>
  47. <a-table-column title="学号" :width="120" data-index="student_num" ellipsis tooltip :filterable="{
  48. filter: (value, record) => (record.student_num ?? '').includes(value),
  49. slotName: 'name-filter',
  50. icon: () => h(IconSearch)
  51. }"></a-table-column>
  52. <a-table-column title="用户名" :width="130" :filterable="{
  53. filter: (value, record) => (record.name ?? '').includes(value),
  54. slotName: 'name-filter',
  55. icon: () => h(IconSearch)
  56. }">
  57. <template #cell="{ record }">
  58. {{ record.name ?? '请使用乐跑登录器更新信息' }}
  59. </template>
  60. </a-table-column>
  61. <a-table-column title="性别" :width="80" ellipsis tooltip :filterable="{
  62. filters: [
  63. { text: '男', value: 1 },
  64. { text: '女', value: 2 }
  65. ],
  66. filter: (value, record) => record.sex == value
  67. }">
  68. <template #cell="{ record }">
  69. <icon-man v-if="record.sex === 1" />
  70. <icon-woman v-else-if="record.sex === 2" />
  71. {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
  72. </template>
  73. </a-table-column>
  74. <a-table-column title="学院" :width="220" data-index="academy_name" ellipsis tooltip :filterable="{
  75. filter: (value, record) => (record.academy_name ?? '').includes(value),
  76. slotName: 'name-filter',
  77. icon: () => h(IconSearch)
  78. }"></a-table-column>
  79. <a-table-column title="跑区" :width="130" :filterable="{
  80. filter: (value, record) => (record.name ?? '').includes(value),
  81. slotName: 'name-filter',
  82. icon: () => h(IconSearch)
  83. }">
  84. <template #cell="{ record }">
  85. <div class="vipcontent">
  86. <span>{{ record.area || '随机分配' }} </span>
  87. <!-- <img src="@/assets/vip.svg" alt="vip" height="20" v-if="record.area"> -->
  88. </div>
  89. </template>
  90. </a-table-column>
  91. <a-table-column title="通知邮箱" :width="180" data-index="email" ellipsis tooltip :filterable="{
  92. filter: (value, record) => (record.email ?? '').includes(value),
  93. slotName: 'name-filter',
  94. icon: () => h(IconSearch)
  95. }"></a-table-column>
  96. <a-table-column title="帐号状态" :width="100" ellipsis tooltip :filterable="{
  97. filters: [
  98. { text: '正常', value: 1 },
  99. { text: '需登录', value: 0 },
  100. { text: '状态异常', value: 2 },
  101. ],
  102. filter: (value, record) => record.state == value
  103. }">
  104. <template #cell="{ record }">
  105. <div v-if="record.state === 0" class="state">
  106. <div class="circle zero"></div>需登录
  107. </div>
  108. <div v-else-if="record.state === 1" class="state">
  109. <div class="circle one"></div>正常
  110. </div>
  111. <div v-else class="state">
  112. <div class="circle else"></div>状态异常
  113. </div>
  114. </template>
  115. </a-table-column>
  116. <a-table-column title="自动乐跑" :width="100" ellipsis tooltip :filterable="{
  117. filters: [
  118. { text: '开启', value: 1 },
  119. { text: '关闭', value: 0 }
  120. ],
  121. filter: (value, record) => record.auto_run == value
  122. }">
  123. <template #cell="{ record }">
  124. <a-tag color="green" v-if="record.auto_run">开启</a-tag>
  125. <a-tag color="red" v-else>关闭</a-tag>
  126. </template>
  127. </a-table-column>
  128. <a-table-column title="自动乐跑时段" :width="120" ellipsis tooltip>
  129. <template #cell="{ record }">
  130. {{ autoTimeLabel(record.auto_time) }}
  131. </template>
  132. </a-table-column>
  133. <a-table-column title="累计次数" data-index="total_num" :width="110" ellipsis tooltip :sortable="{
  134. sortDirections: ['ascend', 'descend']
  135. }"></a-table-column>
  136. <a-table-column title="剩余次数" :width="110" ellipsis tooltip>
  137. <template #cell="{ record }">
  138. {{ record.term_num - record.total_num > 0 ? (record.term_num - record.total_num) : '已完成' }}
  139. </template>
  140. </a-table-column>
  141. <a-table-column title="添加时间" :width="170" ellipsis tooltip :sortable="{
  142. sortDirections: ['ascend', 'descend']
  143. }">
  144. <template #cell="{ record }">
  145. {{ stramptoTime(record.create_time) }}
  146. </template>
  147. </a-table-column>
  148. <a-table-column title="更新时间" :width="170" ellipsis tooltip>
  149. <template #cell="{ record }">
  150. {{ stramptoTime(record.update_time || record.create_time) }}
  151. </template>
  152. </a-table-column>
  153. <a-table-column title="备注" :width="200" ellipsis tooltip>
  154. <template #cell="{ record }">
  155. {{ record.notes }}
  156. </template>
  157. </a-table-column>
  158. <a-table-column title="" fixed="right" :width="100">
  159. <template #cell="{ record }">
  160. <a-dropdown :popup-max-height="false" trigger="hover">
  161. <a-button>操作 <icon-down /></a-button>
  162. <template #content>
  163. <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
  164. <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
  165. <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
  166. '开启' }}自动乐跑</a-doption>
  167. <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
  168. </template>
  169. </a-dropdown>
  170. </template>
  171. </a-table-column>
  172. </template>
  173. </a-table>
  174. </a-card>
  175. </div>
  176. <!-- 账号编辑对话框 -->
  177. <a-modal v-model:visible="visible" title="编辑账号" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
  178. :ok-loading="ok_loading" esc-to-close closable>
  179. <a-form :model="form">
  180. <a-form-item field="student_num" label="学号">
  181. <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" />
  182. </a-form-item>
  183. <a-form-item field="email" label="通知邮箱">
  184. <a-input v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" />
  185. </a-form-item>
  186. <a-form-item field="area" label="乐跑跑区">
  187. <a-select v-model="form.area" placeholder="请选择乐跑跑区" default-value="">
  188. <a-option value="">随机分配</a-option>
  189. <a-option v-for="(item, index) in area" :key="index" :value="item">
  190. <span class="vipcontent">
  191. <span>{{ item }} </span>
  192. <!-- <img src="@/assets/vip.svg" alt="vip" height="20"> -->
  193. </span>
  194. </a-option>
  195. </a-select>
  196. </a-form-item>
  197. <a-form-item field="area" label="自动乐跑时段">
  198. <a-select v-model="form.auto_time" placeholder="请选择每天自动乐跑的时段" :options="auto_time" />
  199. </a-form-item>
  200. <a-form-item field="notes" label="备注">
  201. <a-textarea v-model="form.notes" placeholder="添加对账号的备注(非必填)" :max-length="{length:50}" allow-clear
  202. show-word-limit />
  203. </a-form-item>
  204. <!-- <a-form-item field="distance" label="距离区间">
  205. <a-select v-model="form.distance" placeholder="请选择距离区间">
  206. <a-option :value="0">默认(2~4Km)</a-option>
  207. <a-option v-for="(item, index) in distance" :key="index" :value="item.value">
  208. <div class="vipcontent">
  209. <div>{{ item.label }} </div>
  210. <img src="@/assets/vip.svg" alt="vip" height="20">
  211. </div>
  212. </a-option>
  213. </a-select>
  214. </a-form-item> -->
  215. </a-form>
  216. </a-modal>
  217. </template>
  218. <script setup>
  219. import { ref, reactive, h } from 'vue'
  220. import { useUserStore } from '@/store/modules/user'
  221. import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun } from '@/api/lepao'
  222. import { Modal, Notification, Message } from '@arco-design/web-vue'
  223. import { IconSearch } from '@arco-design/web-vue/es/icon'
  224. import userCard from './components/userCard.vue'
  225. import { isElectron } from '@/utils/electron'
  226. const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区"]
  227. const distance = [
  228. { label: '女生(1.6~2Km)', value: [1.60, 2.00] },
  229. { label: '男生(2.0~2.4Km)', value: [2.00, 2.40] }
  230. ]
  231. const auto_time = Array.from({ length: 17 }, (_, i) => {
  232. const hour = i + 7
  233. return {
  234. label: `${hour} ~ ${hour + 1}时`,
  235. value: hour
  236. }
  237. })
  238. const autoTimeLabel = (val) => {
  239. const match = auto_time.find(item => item.value === val)
  240. return match ? match.label : '-'
  241. }
  242. const data = ref([])
  243. const loading = ref(false)
  244. const email = ref('')
  245. const getuser = async () => {
  246. const userStore = useUserStore()
  247. let userInfo = await userStore.getInfo()
  248. email.value = userInfo.email
  249. }
  250. const visible = ref(false)
  251. const ok_loading = ref(false)
  252. const form = reactive({
  253. id: null,
  254. student_num: '',
  255. email: '',
  256. distance: [2.00, 4.00],
  257. area: '',
  258. auto_time: 8,
  259. notes: ''
  260. })
  261. const download = () => {
  262. const a = document.createElement('a');
  263. a.href = 'https:\/\/lepao-cloud.xxoo365.top\/down.php\/682d99f9694c6fe76b64b86c5741a2d8.pdf';
  264. a.download = 'RunForge操作说明.pdf'
  265. a.target = '_blank'
  266. document.body.appendChild(a);
  267. a.click();
  268. document.body.removeChild(a);
  269. }
  270. const editAccount = (item) => {
  271. if (item) {
  272. form.id = item.id
  273. form.student_num = item.student_num
  274. form.email = item.email
  275. form.area = item.area
  276. form.auto_time = item.auto_time
  277. form.notes = item.notes
  278. } else {
  279. form.id = null
  280. form.student_num = ''
  281. form.email = email.value
  282. form.notes = ''
  283. }
  284. visible.value = true
  285. }
  286. const handleBeforeOk = async (done) => {
  287. try {
  288. ok_loading.value = true
  289. const { student_num, email } = form
  290. if (!student_num || !email) {
  291. Message.error('请填写完整的账号信息')
  292. return false
  293. }
  294. const studentNumRegex = /^\d{10}$/
  295. if (!studentNumRegex.test(student_num)) {
  296. Message.error('请检查学号格式是否正确')
  297. return false
  298. }
  299. const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
  300. if (!emailRegex.test(email)) {
  301. Message.error('请检查邮箱格式是否正确')
  302. return false
  303. }
  304. let data = {
  305. ...form,
  306. min_distance: form.distance[0],
  307. max_distance: form.distance[1]
  308. }
  309. const res = await addAccount(data)
  310. if (!res || res.code !== 0) {
  311. Notification.error({
  312. title: '保存乐跑账号失败!',
  313. content: res?.msg ?? '请稍后再试'
  314. })
  315. return false
  316. }
  317. Message.success('保存成功!')
  318. done()
  319. getAccounts()
  320. } catch (error) {
  321. Notification.error({
  322. title: '保存乐跑账号失败!',
  323. content: error.message || '请稍后再试'
  324. })
  325. return false
  326. } finally {
  327. ok_loading.value = false
  328. }
  329. }
  330. const handleCancel = () => {
  331. visible.value = false
  332. }
  333. const getAccounts = async () => {
  334. try {
  335. loading.value = true
  336. const res = await accountList()
  337. if (!res || res.code !== 0)
  338. return Notification.error({
  339. title: '获取账号列表失败!',
  340. content: res?.msg ?? '请稍后再试'
  341. })
  342. data.value = res.data
  343. } catch (error) {
  344. Notification.error({
  345. title: '获取账号列表失败!',
  346. content: error.message || '请稍后再试'
  347. })
  348. } finally {
  349. loading.value = false
  350. }
  351. }
  352. const SingleRun = async (item) => {
  353. if (item.state !== 1)
  354. return Notification.error({
  355. title: '当前乐跑账号需登录,请登录后再试',
  356. content: '如有疑问请联系RunForge客服'
  357. })
  358. Modal.confirm({
  359. title: '开始乐跑',
  360. content: () => h('div', [
  361. h('p', `您是否要为${item.name}乐跑?若乐跑成功将扣减乐跑次数`)
  362. ]),
  363. onOk: async () => {
  364. const res = await singleRun({ student_num: item.student_num })
  365. if (!res || res.code !== 0)
  366. return Notification.error({
  367. title: '提交乐跑任务失败',
  368. content: res?.msg ?? '请稍后再试'
  369. })
  370. Message.success('提交乐跑任务成功!')
  371. }
  372. })
  373. }
  374. const DeleteAccount = async (item) => {
  375. Modal.confirm({
  376. title: '解绑账号',
  377. content: () => h('div', [
  378. h('p', '您是否要解绑该账号?该操作不可逆')
  379. ]),
  380. onOk: async () => {
  381. const res = await deleteAccount({ id: item.id })
  382. if (!res || res.code !== 0)
  383. return Notification.error({
  384. title: '解绑失败',
  385. content: res?.msg ?? '请稍后再试'
  386. })
  387. Message.success('解绑成功!')
  388. getAccounts()
  389. }
  390. })
  391. }
  392. const ChangeAutoRun = async (record) => {
  393. const oldValue = record.auto_run;
  394. record.auto_run = oldValue === 1 ? 0 : 1;
  395. try {
  396. const res = await changeAutoRun({ id: record.id });
  397. if (!res || res.code !== 0) {
  398. record.auto_run = oldValue;
  399. Message.error('切换自动乐跑状态失败!');
  400. } else {
  401. Message.success(`自动乐跑状态已${record.auto_run === 1 ? '开启' : '关闭'}`)
  402. getAccounts()
  403. }
  404. } catch (error) {
  405. record.auto_run = oldValue;
  406. Message.error('切换自动乐跑状态失败!');
  407. }
  408. }
  409. const stramptoTime = (time) => {
  410. return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
  411. }
  412. getAccounts()
  413. getuser()
  414. </script>
  415. <style scoped lang="less">
  416. .container {
  417. padding: 0 20px 20px 20px;
  418. }
  419. .table {
  420. margin-top: 15px;
  421. .state {
  422. display: flex;
  423. align-items: center;
  424. .circle {
  425. border-radius: 50%;
  426. height: 8px;
  427. min-height: 8px;
  428. width: 8px;
  429. min-width: 8px;
  430. margin-right: 5px;
  431. }
  432. .zero {
  433. background-color: rgb(var(--orange-6));
  434. }
  435. .one {
  436. background-color: rgb(var(--green-6));
  437. }
  438. .else {
  439. background-color: rgb(var(--red-6));
  440. }
  441. }
  442. }
  443. .custom-filter {
  444. padding: 20px;
  445. background: var(--color-bg-5);
  446. border: 1px solid var(--color-neutral-3);
  447. border-radius: var(--border-radius-medium);
  448. box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
  449. }
  450. .custom-filter-footer {
  451. display: flex;
  452. justify-content: space-between;
  453. }
  454. .vipcontent {
  455. display: flex;
  456. align-items: center;
  457. }
  458. </style>