index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. <template>
  2. <div class="container">
  3. <Breadcrumb :items="['校园乐跑', '乐跑账号']" />
  4. <userCard />
  5. <a-card title="账号列表" style="margin-top: 15px;">
  6. <div class="buttonGroup">
  7. <a-button type="primary" size="large" @click="editAccount()">
  8. <template #icon>
  9. <icon-plus />
  10. </template>
  11. 添加账号
  12. </a-button>
  13. <a-button size="large" @click="download" style="margin-left: 10px;">
  14. <template #icon>
  15. <icon-question-circle />
  16. </template>
  17. 操作说明
  18. </a-button>
  19. <a-button size="large" @click="$router.push('/download/down')" style="margin-left: 10px;" v-if="!isElectron()">
  20. <template #icon>
  21. <icon-download />
  22. </template>
  23. 客户端/登录器下载
  24. </a-button>
  25. </div>
  26. <a-row class="queryForm">
  27. <a-col :flex="1">
  28. <a-form :model="queryDataForm" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
  29. label-align="left">
  30. <a-row :gutter="16">
  31. <a-col :span="8">
  32. <a-form-item field="area" label="跑区">
  33. <a-select v-model="queryDataForm.area" placeholder="请选择乐跑跑区" default-value="">
  34. <a-option value="">所有跑区</a-option>
  35. <a-option v-for="(item, index) in area" :key="index" :value="item">
  36. {{ item }}
  37. </a-option>
  38. </a-select>
  39. </a-form-item>
  40. </a-col>
  41. <a-col :span="8">
  42. <a-form-item field="email" label="邮箱">
  43. <a-input v-model="queryDataForm.email" allow-clear />
  44. </a-form-item>
  45. </a-col>
  46. <a-col :span="8">
  47. <a-form-item field="username" label="姓名">
  48. <a-input v-model="queryDataForm.username" allow-clear />
  49. </a-form-item>
  50. </a-col>
  51. <a-col :span="8">
  52. <a-form-item field="student_num" label="学号">
  53. <a-input v-model="queryDataForm.student_num" allow-clear />
  54. </a-form-item>
  55. </a-col>
  56. <a-col :span="8">
  57. <a-form-item field="area" label="状态">
  58. <a-select v-model="queryDataForm.state" :options="state" placeholder="请选择账号状态" :default-value="-1" />
  59. </a-form-item>
  60. </a-col>
  61. </a-row>
  62. </a-form>
  63. </a-col>
  64. <a-divider style="height: 84px" direction="vertical" />
  65. <a-col :flex="'86px'" style="text-align: right">
  66. <a-space direction="vertical" :size="18">
  67. <a-button type="primary" @click="search">
  68. <template #icon>
  69. <icon-search />
  70. </template>
  71. 搜索
  72. </a-button>
  73. <a-button @click="reset">
  74. <template #icon>
  75. <icon-refresh />
  76. </template>
  77. 重置
  78. </a-button>
  79. </a-space>
  80. </a-col>
  81. </a-row>
  82. <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading" expandable :scroll="{
  83. x: 1600
  84. }" :pagination="{
  85. showPageSize: true,
  86. showJumper: true,
  87. showTotal: true,
  88. pageSize: pagination.pagesize,
  89. current: pagination.current,
  90. total: pagination.total
  91. }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
  92. <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
  93. <div class="custom-filter">
  94. <a-space direction="vertical">
  95. <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
  96. <div class="custom-filter-footer">
  97. <a-button @click="handleFilterReset">重置</a-button>
  98. <a-button @click="handleFilterConfirm">确定</a-button>
  99. </div>
  100. </a-space>
  101. </div>
  102. </template>
  103. <template #columns>
  104. <a-table-column title="" :width="50" data-index="user_avatar" tooltip>
  105. <template #cell="{ record }">
  106. <a-avatar>
  107. <img :alt="record.name ?? ''"
  108. :src="record.user_avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
  109. </a-avatar>
  110. </template>
  111. </a-table-column>
  112. <a-table-column title="学号" :width="115" data-index="student_num" ellipsis tooltip></a-table-column>
  113. <a-table-column title="姓名" :width="130">
  114. <template #cell="{ record }">
  115. {{ record.name ?? '请使用乐跑登录器更新账号信息' }}
  116. </template>
  117. </a-table-column>
  118. <a-table-column title="性别" :width="80" ellipsis tooltip :filterable="{
  119. filters: [
  120. { text: '男', value: 1 },
  121. { text: '女', value: 2 }
  122. ],
  123. filter: (value, record) => record.sex == value
  124. }">
  125. <template #cell="{ record }">
  126. <icon-man v-if="record.sex === 1" />
  127. <icon-woman v-else-if="record.sex === 2" />
  128. {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
  129. </template>
  130. </a-table-column>
  131. <a-table-column title="学院" :width="220" data-index="academy_name" ellipsis tooltip :filterable="{
  132. filter: (value, record) => (record.academy_name ?? '').includes(value),
  133. slotName: 'name-filter',
  134. icon: () => h(IconSearch)
  135. }"></a-table-column>
  136. <a-table-column title="跑区" :width="130">
  137. <template #cell="{ record }">
  138. <div class="vipcontent">
  139. <span>{{ record.area || '随机分配' }} </span>
  140. <!-- <img src="@/assets/vip.svg" alt="vip" height="20" v-if="record.area"> -->
  141. </div>
  142. </template>
  143. </a-table-column>
  144. <a-table-column title="通知邮箱" :width="180" data-index="email" ellipsis tooltip></a-table-column>
  145. <a-table-column title="帐号状态" :width="100" ellipsis tooltip>
  146. <template #cell="{ record }">
  147. <div v-if="record.state === 0" class="state">
  148. <div class="circle zero"></div>需登录
  149. </div>
  150. <div v-else-if="record.state === 1" class="state">
  151. <div class="circle one"></div>正常
  152. </div>
  153. <div v-else class="state">
  154. <div class="circle else"></div>状态异常
  155. </div>
  156. </template>
  157. </a-table-column>
  158. <a-table-column title="自动乐跑" :width="100" ellipsis tooltip :filterable="{
  159. filters: [
  160. { text: '开启', value: 1 },
  161. { text: '关闭', value: 0 }
  162. ],
  163. filter: (value, record) => record.auto_run == value
  164. }">
  165. <template #cell="{ record }">
  166. <a-tag color="green" v-if="record.auto_run">开启</a-tag>
  167. <a-tag color="red" v-else>关闭</a-tag>
  168. </template>
  169. </a-table-column>
  170. <a-table-column title="自动乐跑时段" :width="120" ellipsis tooltip>
  171. <template #cell="{ record }">
  172. {{ autoTimeLabel(record.auto_time) }}
  173. </template>
  174. </a-table-column>
  175. <a-table-column title="累计次数" :width="88" ellipsis tooltip>
  176. <template #cell="{ record }">
  177. {{ record.term_num - record.total_num > 0 ? `${record.total_num} / ${record.term_num}` :
  178. '已完成' }}
  179. </template>
  180. </a-table-column>
  181. <a-table-column title="添加时间" :width="145" ellipsis tooltip :sortable="{
  182. sortDirections: ['ascend', 'descend']
  183. }">
  184. <template #cell="{ record }">
  185. {{ stramptoTime(record.create_time) }}
  186. </template>
  187. </a-table-column>
  188. <a-table-column title="上次登录时间" :width="145" ellipsis tooltip>
  189. <template #cell="{ record }">
  190. {{ record.update_time ? stramptoTime(record.update_time) : '待登录' }}
  191. </template>
  192. </a-table-column>
  193. <a-table-column title="备注" :width="200" ellipsis tooltip>
  194. <template #cell="{ record }">
  195. {{ record.notes }}
  196. </template>
  197. </a-table-column>
  198. <a-table-column title="" fixed="right" :width="100">
  199. <template #cell="{ record }">
  200. <a-dropdown :popup-max-height="false" trigger="hover">
  201. <a-button>操作 <icon-down /></a-button>
  202. <template #content>
  203. <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
  204. <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
  205. <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
  206. '开启' }}自动乐跑</a-doption>
  207. <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
  208. </template>
  209. </a-dropdown>
  210. </template>
  211. </a-table-column>
  212. </template>
  213. </a-table>
  214. </a-card>
  215. </div>
  216. <!-- 账号编辑对话框 -->
  217. <a-modal v-model:visible="visible" title="编辑账号" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
  218. :ok-loading="ok_loading" esc-to-close closable>
  219. <a-form :model="form">
  220. <a-form-item field="student_num" label="学号">
  221. <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" allow-clear />
  222. </a-form-item>
  223. <a-form-item field="email" label="通知邮箱">
  224. <a-auto-complete :data="email" @search="handleSearch" v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知"
  225. allow-clear />
  226. </a-form-item>
  227. <a-form-item field="area" label="乐跑跑区">
  228. <a-select v-model="form.area" placeholder="请选择乐跑跑区" default-value="">
  229. <a-option value="">随机分配</a-option>
  230. <a-option v-for="(item, index) in area" :key="index" :value="item">
  231. <span class="vipcontent">
  232. <span>{{ item }} </span>
  233. <!-- <img src="@/assets/vip.svg" alt="vip" height="20"> -->
  234. </span>
  235. </a-option>
  236. </a-select>
  237. </a-form-item>
  238. <a-form-item field="auto_run" label="自动乐跑开关">
  239. <a-switch v-model="form.auto_run" :checked-value="1" :unchecked-value="0" />
  240. </a-form-item>
  241. <a-form-item field="area" label="自动乐跑时段">
  242. <a-select v-model="form.auto_time" placeholder="请选择每天自动乐跑的时段" :options="auto_time" />
  243. </a-form-item>
  244. <a-form-item field="notes" label="备注">
  245. <a-textarea v-model="form.notes" placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
  246. show-word-limit />
  247. </a-form-item>
  248. </a-form>
  249. </a-modal>
  250. </template>
  251. <script setup>
  252. import { ref, reactive, onUnmounted, onMounted, h } from 'vue'
  253. import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun } from '@/api/lepao'
  254. import { Modal, Notification, Message } from '@arco-design/web-vue'
  255. import { IconSearch } from '@arco-design/web-vue/es/icon'
  256. import userCard from '@/components/userCard/userCard.vue'
  257. import { isElectron } from '@/utils/electron'
  258. const email = ref([])
  259. const queryDataForm = reactive({
  260. area: '',
  261. student_num: '',
  262. email: '',
  263. username: '',
  264. state: -1
  265. })
  266. const queryData = reactive({
  267. area: '',
  268. student_num: '',
  269. email: '',
  270. username: '',
  271. state: -1
  272. })
  273. const pagination = reactive({
  274. total: 0,
  275. current: 1, // 默认从第1页开始
  276. pagesize: 20
  277. })
  278. const handleSearch = (value) => {
  279. const emailSuffix = ["qq.com", "ctbu.edu.cn", "163.com"]
  280. const input = (value || "").trim()
  281. if (!input) {
  282. email.value = []
  283. return
  284. }
  285. // 没有输入 @,直接拼接所有后缀
  286. if (!input.includes("@")) {
  287. email.value = emailSuffix.map(suffix => `${input}@${suffix}`)
  288. return
  289. }
  290. // 输入了 @ 但结尾是 @,拼接所有后缀
  291. if (input.endsWith("@")) {
  292. email.value = emailSuffix.map(suffix => `${input}${suffix}`)
  293. return
  294. }
  295. // 输入了 @ 且有部分后缀,智能匹配
  296. const [prefix, suffixPart] = input.split("@")
  297. email.value = emailSuffix
  298. .filter(suffix => suffix.startsWith(suffixPart))
  299. .map(suffix => `${prefix}@${suffix}`)
  300. }
  301. const area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区"]
  302. const state = [
  303. { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
  304. ]
  305. const search = () => {
  306. pagination.current = 1
  307. queryData.area = queryDataForm.area
  308. queryData.student_num = queryDataForm.student_num
  309. queryData.email = queryDataForm.email
  310. queryData.username = queryDataForm.username
  311. queryData.state = queryDataForm.state
  312. getAccountsAsync()
  313. }
  314. const reset = () => {
  315. queryDataForm.area = ''
  316. queryDataForm.student_num = ''
  317. queryDataForm.email = ''
  318. queryDataForm.username = ''
  319. queryDataForm.state = -1
  320. queryData.area = ''
  321. queryData.student_num = ''
  322. queryData.email = ''
  323. queryData.username = ''
  324. queryData.state = -1
  325. getAccountsAsync()
  326. }
  327. // 分页 - 页码变化
  328. const handlePageChange = (page) => {
  329. pagination.current = page
  330. getAccountsAsync()
  331. }
  332. // 分页 - 每页条数变化
  333. const handlePageSizeChange = (size) => {
  334. pagination.pagesize = size
  335. pagination.current = 1 // 页大小变化后回到第一页
  336. getAccountsAsync()
  337. }
  338. const auto_time = Array.from({ length: 17 }, (_, i) => {
  339. const hour = i + 7
  340. return {
  341. label: `${hour} ~ ${hour + 1}时`,
  342. value: hour
  343. }
  344. })
  345. const autoTimeLabel = (val) => {
  346. const match = auto_time.find(item => item.value === val)
  347. return match ? match.label : '-'
  348. }
  349. const data = ref([])
  350. const loading = ref(false)
  351. const visible = ref(false)
  352. const ok_loading = ref(false)
  353. const form = reactive({
  354. id: null,
  355. student_num: '',
  356. email: '',
  357. distance: [2.00, 4.00],
  358. area: '',
  359. auto_time: 8,
  360. auto_run: 1,
  361. notes: ''
  362. })
  363. const download = () => {
  364. const a = document.createElement('a');
  365. a.href = 'https:\/\/lepao-cloud.xxoo365.top\/down.php\/682d99f9694c6fe76b64b86c5741a2d8.pdf';
  366. a.download = 'RunForge操作说明.pdf'
  367. a.target = '_blank'
  368. document.body.appendChild(a);
  369. a.click();
  370. document.body.removeChild(a);
  371. }
  372. const editAccount = (item) => {
  373. if (item) {
  374. form.id = item.id
  375. form.student_num = item.student_num
  376. form.email = item.email
  377. form.area = item.area
  378. form.auto_time = item.auto_time
  379. form.auto_run = item.auto_run
  380. form.notes = item.notes
  381. } else {
  382. form.id = null
  383. form.student_num = ''
  384. form.email = ''
  385. form.auto_run = 1
  386. form.auto_time = 7
  387. form.area = ''
  388. form.notes = ''
  389. }
  390. visible.value = true
  391. }
  392. const handleBeforeOk = async (done) => {
  393. try {
  394. ok_loading.value = true
  395. const { student_num, email } = form
  396. if (!student_num || !email) {
  397. Message.error('请填写完整的账号信息')
  398. return false
  399. }
  400. const studentNumRegex = /^\d{10}$/
  401. if (!studentNumRegex.test(student_num)) {
  402. Message.error('请检查学号格式是否正确')
  403. return false
  404. }
  405. const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
  406. if (!emailRegex.test(email)) {
  407. Message.error('请检查邮箱格式是否正确')
  408. return false
  409. }
  410. let data = {
  411. ...form,
  412. min_distance: form.distance[0],
  413. max_distance: form.distance[1]
  414. }
  415. const res = await addAccount(data)
  416. if (!res || res.code !== 0) {
  417. Notification.error({
  418. title: '保存乐跑账号失败!',
  419. content: res?.msg ?? '请稍后再试'
  420. })
  421. return false
  422. }
  423. Message.success('保存成功!')
  424. done()
  425. getAccountsAsync()
  426. } catch (error) {
  427. Notification.error({
  428. title: '保存乐跑账号失败!',
  429. content: error.message || '请稍后再试'
  430. })
  431. return false
  432. } finally {
  433. ok_loading.value = false
  434. }
  435. }
  436. const handleCancel = () => {
  437. visible.value = false
  438. }
  439. const getAccountsAsync = async () => {
  440. loading.value = true
  441. await getAccounts()
  442. loading.value = false
  443. }
  444. const getAccounts = async () => {
  445. try {
  446. const reqData = {
  447. ...queryData,
  448. pagesize: pagination.pagesize,
  449. current: pagination.current
  450. }
  451. const res = await accountList(reqData)
  452. if (!res || res.code !== 0)
  453. return Notification.error({
  454. title: '获取乐跑账号数据失败!',
  455. content: res?.msg ?? '请稍后再试'
  456. })
  457. data.value = res.data
  458. pagination.total = res.pagination.total
  459. } catch (error) {
  460. Notification.error({
  461. title: '获取乐跑账号数据失败!',
  462. content: error.message || '请稍后再试'
  463. })
  464. }
  465. }
  466. const SingleRun = async (item) => {
  467. if (item.state !== 1)
  468. return Notification.warning({
  469. title: '当前乐跑账号需登录,请登录后再试',
  470. content: '如有疑问请联系RunForge客服'
  471. })
  472. Modal.confirm({
  473. title: '开始乐跑',
  474. content: () => h('div', [
  475. h('p', `您是否要为 ${item.name}(${item.student_num}) 乐跑?若乐跑成功将扣减乐跑次数`)
  476. ]),
  477. onOk: async () => {
  478. const res = await singleRun({ student_num: item.student_num })
  479. if (!res || res.code !== 0)
  480. return Notification.error({
  481. title: '提交乐跑任务失败',
  482. content: res?.msg ?? '请稍后再试'
  483. })
  484. Message.success('提交乐跑任务成功!')
  485. }
  486. })
  487. }
  488. const DeleteAccount = async (item) => {
  489. Modal.confirm({
  490. title: '解绑账号',
  491. content: () => h('div', [
  492. h('p', '您是否要解绑该账号?')
  493. ]),
  494. onOk: async () => {
  495. const res = await deleteAccount({ id: item.id })
  496. if (!res || res.code !== 0)
  497. return Notification.error({
  498. title: '解绑失败',
  499. content: res?.msg ?? '请稍后再试'
  500. })
  501. Message.success('解绑成功!')
  502. getAccounts()
  503. }
  504. })
  505. }
  506. const ChangeAutoRun = async (record) => {
  507. const oldValue = record.auto_run;
  508. record.auto_run = oldValue === 1 ? 0 : 1;
  509. try {
  510. const res = await changeAutoRun({ id: record.id });
  511. if (!res || res.code !== 0) {
  512. record.auto_run = oldValue;
  513. Message.error('切换自动乐跑状态失败!');
  514. } else {
  515. Message.success(`自动乐跑状态已${record.auto_run === 1 ? '开启' : '关闭'}`)
  516. getAccounts()
  517. }
  518. } catch (error) {
  519. record.auto_run = oldValue;
  520. Message.error('切换自动乐跑状态失败!');
  521. }
  522. }
  523. const stramptoTime = (time) => {
  524. return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  525. }
  526. let timer = null
  527. // 轮询
  528. const startPolling = () => {
  529. if (!timer) {
  530. timer = setInterval(async () => {
  531. await getAccounts()
  532. }, 5000)
  533. }
  534. }
  535. // 停止轮询
  536. const stopPolling = () => {
  537. if (timer) {
  538. clearInterval(timer)
  539. timer = null
  540. }
  541. }
  542. onMounted(async () => {
  543. getAccountsAsync()
  544. startPolling()
  545. })
  546. // 组件销毁时停止轮询
  547. onUnmounted(() => {
  548. stopPolling()
  549. })
  550. </script>
  551. <style scoped lang="less">
  552. .container {
  553. padding: 0 20px 20px 20px;
  554. }
  555. .queryForm {
  556. margin-top: 20px;
  557. padding: 0 15px
  558. }
  559. .table {
  560. font-family: -apple-system, BlinkMacSystemFont;
  561. .state {
  562. display: flex;
  563. align-items: center;
  564. .circle {
  565. border-radius: 50%;
  566. height: 8px;
  567. min-height: 8px;
  568. width: 8px;
  569. min-width: 8px;
  570. margin-right: 5px;
  571. }
  572. .zero {
  573. background-color: rgb(var(--orange-6));
  574. }
  575. .one {
  576. background-color: rgb(var(--green-6));
  577. }
  578. .else {
  579. background-color: rgb(var(--red-6));
  580. }
  581. }
  582. }
  583. .custom-filter {
  584. padding: 20px;
  585. background: var(--color-bg-5);
  586. border: 1px solid var(--color-neutral-3);
  587. border-radius: var(--border-radius-medium);
  588. box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
  589. }
  590. .custom-filter-footer {
  591. display: flex;
  592. justify-content: space-between;
  593. }
  594. .vipcontent {
  595. display: flex;
  596. align-items: center;
  597. }
  598. </style>