index.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020
  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('windows')" style="margin-left: 10px;">
  14. <template #icon>
  15. <icon-desktop />
  16. </template>
  17. Windows操作说明
  18. </a-button>
  19. <a-button size="large" @click="download('android')" style="margin-left: 10px;">
  20. <template #icon>
  21. <icon-mobile />
  22. </template>
  23. 安卓手机操作说明
  24. </a-button>
  25. <a-button size="large" @click="download('iphone')" style="margin-left: 10px;">
  26. <template #icon>
  27. <icon-mobile />
  28. </template>
  29. iPhone操作说明
  30. </a-button>
  31. <a-button size="large" @click="download('page')" style="margin-left: 10px;" v-if="!isElectron()">
  32. <template #icon>
  33. <icon-download />
  34. </template>
  35. 客户端/登录器下载
  36. </a-button> -->
  37. </div>
  38. <a-row class="queryForm">
  39. <a-col :flex="1">
  40. <a-form :model="queryDataForm" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
  41. label-align="left">
  42. <a-row :gutter="16">
  43. <a-col :span="6">
  44. <a-form-item field="area" label="跑区">
  45. <a-select v-model="queryDataForm.area" placeholder="请选择乐跑跑区" default-value="">
  46. <a-option value="">所有跑区</a-option>
  47. <a-option v-for="(item, index) in area" :key="index" :value="item">
  48. {{ item }}
  49. </a-option>
  50. </a-select>
  51. </a-form-item>
  52. </a-col>
  53. <a-col :span="6">
  54. <a-form-item field="email" label="邮箱">
  55. <a-input v-model="queryDataForm.email" allow-clear />
  56. </a-form-item>
  57. </a-col>
  58. <a-col :span="6">
  59. <a-form-item field="username" label="姓名">
  60. <a-input v-model="queryDataForm.username" allow-clear />
  61. </a-form-item>
  62. </a-col>
  63. <a-col :span="6">
  64. <a-form-item field="student_num" label="学号">
  65. <a-input v-model="queryDataForm.student_num" allow-clear />
  66. </a-form-item>
  67. </a-col>
  68. <a-col :span="6">
  69. <a-form-item field="area" label="状态">
  70. <a-select v-model="queryDataForm.state" :options="state" placeholder="请选择账号状态" :default-value="-1" />
  71. </a-form-item>
  72. </a-col>
  73. <a-col :span="6">
  74. <a-form-item field="auto_time" label="乐跑时段">
  75. <a-select v-model="queryDataForm.auto_time" placeholder="请选择自动乐跑时段" :default-value="-1">
  76. <a-option :value="0">所有时段</a-option>
  77. <a-option v-for="(item, index) in auto_time" :key="index" :value="item.value">
  78. {{ item.label }}
  79. </a-option>
  80. </a-select>
  81. </a-form-item>
  82. </a-col>
  83. <a-col :span="6">
  84. <a-form-item field="queryTime" label="更新时间">
  85. <a-range-picker v-model="queryDataForm.queryTime" show-time format="YY-MM-DD HH:mm" value-format="x"
  86. @ok="search()" />
  87. </a-form-item>
  88. </a-col>
  89. </a-row>
  90. </a-form>
  91. </a-col>
  92. <a-divider style="height: 84px" direction="vertical" />
  93. <a-col :flex="'86px'" style="text-align: right">
  94. <a-space direction="vertical" :size="18">
  95. <a-button type="primary" @click="search">
  96. <template #icon>
  97. <icon-search />
  98. </template>
  99. 搜索
  100. </a-button>
  101. <a-button @click="reset">
  102. <template #icon>
  103. <icon-refresh />
  104. </template>
  105. 重置
  106. </a-button>
  107. </a-space>
  108. </a-col>
  109. </a-row>
  110. <a-alert v-if="notice" style="margin-bottom: 15px;">{{ notice }}</a-alert>
  111. <a-table :data="data" :bordered="false" hoverable class="table" :loading="loading" expandable :scroll="{
  112. x: 1780
  113. }" :pagination="{
  114. showPageSize: true,
  115. showJumper: true,
  116. showTotal: true,
  117. pageSize: pagination.pagesize,
  118. current: pagination.current,
  119. total: pagination.total
  120. }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
  121. <template #name-filter="{ filterValue, setFilterValue, handleFilterConfirm, handleFilterReset }">
  122. <div class="custom-filter">
  123. <a-space direction="vertical">
  124. <a-input :model-value="filterValue[0]" @input="(value) => setFilterValue([value])" />
  125. <div class="custom-filter-footer">
  126. <a-button @click="handleFilterReset">重置</a-button>
  127. <a-button @click="handleFilterConfirm">确定</a-button>
  128. </div>
  129. </a-space>
  130. </div>
  131. </template>
  132. <template #columns>
  133. <a-table-column title="" :width="50" data-index="user_avatar" tooltip>
  134. <template #cell="{ record }">
  135. <a-avatar>
  136. <img :alt="record.name ?? ''"
  137. :src="record.user_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
  138. </a-avatar>
  139. </template>
  140. </a-table-column>
  141. <a-table-column title="学号" :width="110" data-index="student_num" ellipsis tooltip></a-table-column>
  142. <a-table-column title="姓名" :width="120">
  143. <template #cell="{ record }">
  144. {{ record.name ?? '请使用乐跑登录器更新账号信息' }}
  145. </template>
  146. </a-table-column>
  147. <a-table-column title="性别" :width="80" ellipsis tooltip :filterable="{
  148. filters: [
  149. { text: '男', value: 1 },
  150. { text: '女', value: 2 }
  151. ],
  152. filter: (value, record) => record.sex == value
  153. }">
  154. <template #cell="{ record }">
  155. <icon-man v-if="record.sex === 1" />
  156. <icon-woman v-else-if="record.sex === 2" />
  157. {{ record.sex === 1 ? '男' : (record.sex === 2 ? '女' : '') }}
  158. </template>
  159. </a-table-column>
  160. <a-table-column title="班级" :width="200" data-index="class_id" ellipsis tooltip></a-table-column>
  161. <a-table-column title="跑区" :width="120">
  162. <template #cell="{ record }">
  163. {{ record.area || '随机分配' }}
  164. </template>
  165. </a-table-column>
  166. <a-table-column title="通知方式" :width="200" ellipsis tooltip>
  167. <template #cell="{ record }">
  168. <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
  169. <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定' }}</span>
  170. <span v-else><icon-notification-close /> 无通知</span>
  171. </template>
  172. </a-table-column>
  173. <a-table-column title="帐号状态" :width="100" ellipsis tooltip>
  174. <template #cell="{ record }">
  175. <div v-if="record.state === 0" class="state">
  176. <div class="circle zero"></div>需登录
  177. </div>
  178. <div v-else-if="record.state === 1" class="state">
  179. <div class="circle one"></div>正常
  180. </div>
  181. <div v-else class="state">
  182. <div class="circle else"></div>状态异常
  183. </div>
  184. </template>
  185. </a-table-column>
  186. <a-table-column title="自动乐跑" :width="105" ellipsis tooltip :filterable="{
  187. filters: [
  188. { text: '开启', value: 1 },
  189. { text: '关闭', value: 0 }
  190. ],
  191. filter: (value, record) => record.auto_run == value
  192. }">
  193. <template #cell="{ record }">
  194. <a-tag color="green" v-if="record.auto_run">开启</a-tag>
  195. <a-tag color="red" v-else>关闭</a-tag>
  196. </template>
  197. </a-table-column>
  198. <a-table-column title="自动乐跑星期" :width="155" ellipsis tooltip>
  199. <template #cell="{ record }">
  200. <span v-if="record.auto_run && record.auto_day && record.auto_day.length > 0">
  201. {{record.auto_day.slice().sort((a, b) => {
  202. if (a === 0) return 1; if (b === 0) return -1; return a - b;
  203. }).map(day => auto_day.find(item => item.value === day)?.label).join(',').replace(/周/g, '')}}
  204. </span>
  205. <span v-else>-</span>
  206. </template>
  207. </a-table-column>
  208. <a-table-column title="自动乐跑时段" :width="130" ellipsis tooltip>
  209. <template #cell="{ record }">
  210. {{ record.auto_run ? autoTimeLabel(record) : '-' }}
  211. </template>
  212. </a-table-column>
  213. <a-table-column title="目标里程" :width="100" ellipsis tooltip>
  214. <template #cell="{ record }">
  215. {{ record.target_count === 0 ? '不限' : formatKm(record.target_count) }}
  216. </template>
  217. </a-table-column>
  218. <a-table-column title="自动距离区间" :width="155" ellipsis tooltip>
  219. <template #cell="{ record }">
  220. {{ record.auto_run ? formatAutoDistanceRangeLabel(record.auto_run_distance_min_km ?? 2, record.auto_run_distance_max_km ?? 2) : '—' }}
  221. </template>
  222. </a-table-column>
  223. <a-table-column title="自动配速区间" :width="150" ellipsis tooltip>
  224. <template #cell="{ record }">
  225. <span v-if="record.auto_run">{{ formatAutoPaceRangeLabel(record.pace_min_sec_per_km ?? 180, record.pace_max_sec_per_km ?? 600) }}</span>
  226. <span v-else>—</span>
  227. </template>
  228. </a-table-column>
  229. <a-table-column title="本月里程" :width="100" ellipsis tooltip>
  230. <template #cell="{ record }">
  231. {{ formatKm(record.term_num) }}
  232. </template>
  233. </a-table-column>
  234. <a-table-column title="累计里程" :width="110" ellipsis tooltip>
  235. <template #cell="{ record }">
  236. {{ formatKm(record.total_num) }}
  237. </template>
  238. </a-table-column>
  239. <a-table-column title="添加时间" :width="150" ellipsis tooltip :sortable="{
  240. sortDirections: ['ascend', 'descend']
  241. }">
  242. <template #cell="{ record }">
  243. {{ stramptoTime(record.create_time) }}
  244. </template>
  245. </a-table-column>
  246. <a-table-column title="上次更新时间" :width="150" ellipsis tooltip>
  247. <template #cell="{ record }">
  248. {{ record.update_time ? stramptoTime(record.update_time) : '待登录' }}
  249. </template>
  250. </a-table-column>
  251. <a-table-column title="备注" :width="200" ellipsis tooltip>
  252. <template #cell="{ record }">
  253. {{ record.notes }}
  254. </template>
  255. </a-table-column>
  256. <a-table-column title="" fixed="right" :width="100">
  257. <template #cell="{ record }">
  258. <a-dropdown :popup-max-height="false" trigger="hover">
  259. <a-button>操作 <icon-down /></a-button>
  260. <template #content>
  261. <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
  262. <!-- <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption> -->
  263. <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption>
  264. <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
  265. <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
  266. '开启' }}自动乐跑</a-doption>
  267. <a-doption @click="UpdateSelfAccount(record)"><icon-refresh /> 更新账号信息</a-doption>
  268. <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
  269. </template>
  270. </a-dropdown>
  271. </template>
  272. </a-table-column>
  273. </template>
  274. </a-table>
  275. </a-card>
  276. </div>
  277. <!-- 账号编辑对话框 -->
  278. <a-modal v-model:visible="visible" title="编辑账号" @cancel="handleCancel" @before-ok="handleBeforeOk" draggable
  279. :ok-loading="ok_loading" esc-to-close closable>
  280. <a-form :model="form">
  281. <a-form-item label="自动识别" v-if="!form.id">
  282. <a-textarea
  283. v-model="autoFillText"
  284. placeholder="粘贴包含学号、邮箱、跑区等信息的文本,点击“识别并填入”自动填写"
  285. :auto-size="{ minRows: 2, maxRows: 4 }"
  286. allow-clear
  287. />
  288. <template #extra>
  289. <a-space>
  290. <a-button type="primary" @click="handleAutoFill" size="small">识别并填入</a-button>
  291. <span class="auto-fill-tip">示例:2301234567,abc@qq.com,南山校区</span>
  292. </a-space>
  293. </template>
  294. </a-form-item>
  295. <a-form-item field="student_num" label="学号">
  296. <a-input v-model="form.student_num" placeholder="账号所有者学号,填写错误将无法登录" allow-clear />
  297. </a-form-item>
  298. <a-form-item field="notice_type" label="通知方式">
  299. <a-radio-group v-model="form.notice_type">
  300. <a-radio value="email"><icon-email /> 邮件</a-radio>
  301. <!-- <a-radio value="bot"><icon-robot /> 智能机器人</a-radio> -->
  302. <a-radio value="none"><icon-notification-close /> 无通知</a-radio>
  303. </a-radio-group>
  304. </a-form-item>
  305. <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
  306. <a-auto-complete :data="email" @search="handleSearch" v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知"
  307. allow-clear />
  308. </a-form-item>
  309. <a-form-item field="area" label="乐跑跑区">
  310. <a-select v-model="form.area" placeholder="请选择乐跑跑区" default-value="">
  311. <a-option value="">随机分配</a-option>
  312. <a-option v-for="(item, index) in area" :key="index" :value="item">
  313. <span class="vipcontent">
  314. <span>{{ item }} </span>
  315. </span>
  316. </a-option>
  317. </a-select>
  318. </a-form-item>
  319. <a-form-item field="auto_run" label="自动乐跑开关">
  320. <a-switch v-model="form.auto_run" :checked-value="1" :unchecked-value="0" />
  321. </a-form-item>
  322. <a-form-item field="target_count" label="月度目标里程" v-if="form.auto_run">
  323. <a-input-number v-model="form.target_count" placeholder="0–200,0 表示不限制" mode="button" :min="0" :max="200"
  324. :precision="2" :step="0.5" />
  325. <template #extra>
  326. <div>月度里程(Km)达到目标后自动乐跑将关闭;填 0 表示不限制</div>
  327. </template>
  328. </a-form-item>
  329. <a-form-item field="auto_day" label="自动乐跑星期" v-if="form.auto_run">
  330. <a-checkbox-group v-model="form.auto_day" placeholder="请选择每天自动乐跑的星期" :options="auto_day" />
  331. </a-form-item>
  332. <a-form-item field="auto_time" label="自动乐跑时段" v-if="form.auto_run">
  333. <a-select v-model="form.auto_time" placeholder="请选择每天自动乐跑的时段" :options="auto_time" />
  334. </a-form-item>
  335. <a-form-item field="auto_run_distance_range" label="自动距离区间" v-if="form.auto_run">
  336. <a-space>
  337. <a-input-number
  338. v-model="form.auto_run_distance_min_km"
  339. mode="button"
  340. :min="1"
  341. :max="AUTO_SINGLE_RUN_MAX_KM"
  342. :precision="2"
  343. :step="0.1"
  344. placeholder="下限"
  345. />
  346. <span>~</span>
  347. <a-input-number
  348. v-model="form.auto_run_distance_max_km"
  349. mode="button"
  350. :min="1"
  351. :max="AUTO_SINGLE_RUN_MAX_KM"
  352. :precision="2"
  353. :step="0.1"
  354. placeholder="上限"
  355. />
  356. </a-space>
  357. <template #extra>
  358. <div>每次自动乐跑在区间内随机取值;上下限可相同(固定距离);范围 1~10 km</div>
  359. </template>
  360. </a-form-item>
  361. <a-form-item field="pace_range" label="自动配速区间" v-if="form.auto_run">
  362. <a-space>
  363. <a-input v-model="form.pace_min_str" allow-clear placeholder="如 3:00" style="width: 7.5rem" />
  364. <span>–</span>
  365. <a-input v-model="form.pace_max_str" allow-clear placeholder="如 10:00" style="width: 7.5rem" />
  366. </a-space>
  367. <template #extra>
  368. <div>每公里配速,格式为 分:秒(如 5:30);范围 3:00–10:00/km,每次自动乐跑在区间内随机</div>
  369. </template>
  370. </a-form-item>
  371. <a-form-item field="notes" label="备注">
  372. <a-textarea v-model="form.notes" placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
  373. show-word-limit />
  374. </a-form-item>
  375. </a-form>
  376. </a-modal>
  377. <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
  378. <bindBot ref="bindBotRef" />
  379. <SingleRunModal
  380. v-model:visible="singleRunVisible"
  381. :account="singleRunAccount"
  382. @success="getAccountsAsync"
  383. />
  384. </template>
  385. <script setup>
  386. import { ref, reactive, onUnmounted, onMounted, h } from 'vue'
  387. import { accountList, deleteAccount, addAccount, changeAutoRun, updateSelfAccount } from '@/api/lepao'
  388. import { Modal, Notification, Message } from '@arco-design/web-vue'
  389. import { IconSearch } from '@arco-design/web-vue/es/icon'
  390. import userCard from '@/components/userCard/userCard.vue'
  391. import faceModal from '@/components/FaceModal/faceModal.vue'
  392. import bindBot from '@/components/BindBot/bindBot.vue'
  393. import SingleRunModal from '@/components/SingleRunModal/SingleRunModal.vue'
  394. import { useRoute } from 'vue-router'
  395. import { getNotice, getSemesterTimestamps } from '@/utils/util'
  396. import {
  397. formatKm,
  398. formatAutoPaceRangeLabel,
  399. formatAutoDistanceRangeLabel,
  400. formatSecPerKmAsMinSec,
  401. parsePaceMinSecInput
  402. } from '@/utils/lepaoRecord'
  403. /** 与后端 jkes.autoSingleRunMaxKm 默认一致,保存时由服务端再校验 */
  404. const AUTO_SINGLE_RUN_MAX_KM = 10
  405. const notice = ref('')
  406. const email = ref([])
  407. const faceInfo = ref({})
  408. const faceRecoRef = ref(null)
  409. const bindBotRef = ref(null)
  410. const queryDataForm = reactive({
  411. area: '',
  412. student_num: '',
  413. email: '',
  414. username: '',
  415. state: -1,
  416. auto_time: 0,
  417. queryTime: []
  418. })
  419. const queryData = reactive({
  420. area: '',
  421. student_num: '',
  422. email: '',
  423. username: '',
  424. state: -1,
  425. auto_time: 0,
  426. queryTime: []
  427. })
  428. const pagination = reactive({
  429. total: 0,
  430. current: 1, // 默认从第1页开始
  431. pagesize: 20
  432. })
  433. const handleSearch = (value) => {
  434. const emailSuffix = ["qq.com", "163.com"]
  435. const input = (value || "").trim()
  436. if (!input) {
  437. email.value = []
  438. return
  439. }
  440. // 没有输入 @,直接拼接所有后缀
  441. if (!input.includes("@")) {
  442. email.value = emailSuffix.map(suffix => `${input}@${suffix}`)
  443. return
  444. }
  445. // 输入了 @ 但结尾是 @,拼接所有后缀
  446. if (input.endsWith("@")) {
  447. email.value = emailSuffix.map(suffix => `${input}${suffix}`)
  448. return
  449. }
  450. // 输入了 @ 且有部分后缀,智能匹配
  451. const [prefix, suffixPart] = input.split("@")
  452. email.value = emailSuffix
  453. .filter(suffix => suffix.startsWith(suffixPart))
  454. .map(suffix => `${prefix}@${suffix}`)
  455. }
  456. const area = ["学府大道校区", "南山校区"]
  457. const state = [
  458. { label: '全部', value: -1 }, { label: '需登录', value: 0 }, { label: '正常', value: 1 }, { label: '状态异常', value: 2 }
  459. ]
  460. const search = () => {
  461. pagination.current = 1
  462. queryData.area = queryDataForm.area
  463. queryData.student_num = queryDataForm.student_num
  464. queryData.email = queryDataForm.email
  465. queryData.username = queryDataForm.username
  466. queryData.state = queryDataForm.state
  467. queryData.auto_time = queryDataForm.auto_time
  468. queryData.queryTime = queryDataForm.queryTime
  469. getAccountsAsync()
  470. }
  471. const reset = () => {
  472. queryDataForm.area = ''
  473. queryDataForm.student_num = ''
  474. queryDataForm.email = ''
  475. queryDataForm.username = ''
  476. queryDataForm.state = -1
  477. queryDataForm.auto_time = 0
  478. queryDataForm.queryTime = getSemesterTimestamps()
  479. queryData.area = ''
  480. queryData.student_num = ''
  481. queryData.email = ''
  482. queryData.username = ''
  483. queryData.state = -1
  484. queryData.auto_time = 0
  485. queryData.queryTime = getSemesterTimestamps()
  486. getAccountsAsync()
  487. }
  488. // 分页 - 页码变化
  489. const handlePageChange = (page) => {
  490. pagination.current = page
  491. getAccountsAsync()
  492. }
  493. // 分页 - 每页条数变化
  494. const handlePageSizeChange = (size) => {
  495. pagination.pagesize = size
  496. pagination.current = 1 // 页大小变化后回到第一页
  497. getAccountsAsync()
  498. }
  499. const auto_day = [
  500. { label: '周一', value: 1 },
  501. { label: '周二', value: 2 },
  502. { label: '周三', value: 3 },
  503. { label: '周四', value: 4 },
  504. { label: '周五', value: 5 },
  505. { label: '周六', value: 6 },
  506. { label: '周日', value: 0 }
  507. ]
  508. const auto_time = [
  509. { label: '随机分配', value: -1 },
  510. ...Array.from({ length: 17 }, (_, i) => {
  511. const hour = i + 7
  512. return { label: `${hour} ~ ${hour + 1}时`, value: hour }
  513. })
  514. ]
  515. const autoTimeLabel = (record) => {
  516. if (record.auto_time === -1) {
  517. if (record.today_auto_time)
  518. return `随机-今日${record.today_auto_time}时`
  519. return '随机-待分配'
  520. }
  521. const match = auto_time.find(item => item.value === record.auto_time)
  522. return match ? match.label : '-'
  523. }
  524. const data = ref([])
  525. const loading = ref(false)
  526. const visible = ref(false)
  527. const ok_loading = ref(false)
  528. const autoFillText = ref('')
  529. const form = reactive({
  530. id: null,
  531. student_num: '',
  532. email: '',
  533. area: '',
  534. auto_time: -1,
  535. auto_run: 1,
  536. notice_type: 'email',
  537. target_count: 30,
  538. auto_run_distance_min_km: 2.5,
  539. auto_run_distance_max_km: 3,
  540. pace_min_str: '6:00',
  541. pace_max_str: '10:00',
  542. auto_day: [0, 1, 2, 3, 4, 5, 6],
  543. notes: ''
  544. })
  545. const download = (device) => {
  546. const a = document.createElement('a')
  547. if (device === 'windows') {
  548. a.href = 'https:\/\/lepao-cloud.xxoo365.top/down.php\/682d99f9694c6fe76b64b86c5741a2d8.pdf'
  549. a.download = '哪吒乐跑-Windows操作说明.pdf'
  550. } else if (device === 'iphone') {
  551. a.href = 'https:\/\/lepao-cloud.xxoo365.top/down.php\/fba1d571166b4c95592c7c4e624a9390.pdf'
  552. a.download = '哪吒乐跑-iPhone操作说明.pdf'
  553. } else if (device === 'android') {
  554. a.href = 'https:\/\/lepao-cloud.xxoo365.top\/down.php\/3326850aa879cea586677a15af470beb.pdf'
  555. a.download = '哪吒乐跑-Android操作说明.pdf'
  556. } else if (device === 'page') {
  557. a.href = 'https:\/\/download.xxoo365.top'
  558. } else {
  559. return
  560. }
  561. a.target = '_blank'
  562. document.body.appendChild(a)
  563. a.click()
  564. document.body.removeChild(a)
  565. }
  566. const normalizeText = (value) => (value || '').replace(/[\n\r\t,;、]/g, ' ').trim()
  567. const matchArea = (text) => {
  568. const normalized = normalizeText(text)
  569. if (!normalized) return null
  570. const exact = area.find(item => normalized.includes(item))
  571. if (exact) return exact
  572. const areaAliasMap = [
  573. { keywords: ['南山'], value: '南山校区' },
  574. { keywords: ['学府'], value: '学府大道校区' },
  575. ]
  576. const matchedAlias = areaAliasMap.find(item => item.keywords.some(keyword => normalized.includes(keyword)))
  577. return matchedAlias ? matchedAlias.value : null
  578. }
  579. const handleAutoFill = () => {
  580. const text = normalizeText(autoFillText.value)
  581. if (!text) {
  582. Message.warning('请先粘贴需要识别的文本')
  583. return
  584. }
  585. const studentMatch = text.match(/\b2\d{9}\b/)
  586. const emailMatch = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
  587. const areaMatch = matchArea(text)
  588. let hitCount = 0
  589. if (studentMatch?.[0]) {
  590. form.student_num = studentMatch[0]
  591. hitCount += 1
  592. }
  593. if (emailMatch?.[0]) {
  594. form.email = emailMatch[0]
  595. form.notice_type = 'email'
  596. hitCount += 1
  597. }
  598. if (areaMatch) {
  599. form.area = areaMatch
  600. hitCount += 1
  601. }
  602. if (hitCount === 0) {
  603. Message.warning('未识别到可填写信息,请检查文本内容')
  604. return
  605. }
  606. Message.success(`识别成功,已填入 ${hitCount} 项信息`)
  607. }
  608. const editAccount = (item) => {
  609. if (item) {
  610. form.id = item.id
  611. form.student_num = item.student_num
  612. form.email = item.email
  613. form.area = item.area
  614. form.auto_time = item.auto_time
  615. form.auto_run = item.auto_run
  616. form.target_count = Number(item.target_count)
  617. form.auto_run_distance_min_km = Number(item.auto_run_distance_min_km ?? 2)
  618. form.auto_run_distance_max_km = Number(item.auto_run_distance_max_km ?? item.auto_run_distance_min_km ?? 2)
  619. {
  620. const a = formatSecPerKmAsMinSec(item.pace_min_sec_per_km ?? 180)
  621. const b = formatSecPerKmAsMinSec(item.pace_max_sec_per_km ?? 600)
  622. form.pace_min_str = a === '—' ? '3:00' : a
  623. form.pace_max_str = b === '—' ? '10:00' : b
  624. }
  625. form.notice_type = item.notice_type || 'email'
  626. form.auto_day = item.auto_day
  627. form.notes = item.notes
  628. autoFillText.value = ''
  629. } else {
  630. form.id = null
  631. form.student_num = ''
  632. form.email = ''
  633. form.auto_run = 1
  634. form.auto_time = -1
  635. form.target_count = 30
  636. form.auto_run_distance_min_km = 2.5
  637. form.auto_run_distance_max_km = 3
  638. form.pace_min_str = '6:00'
  639. form.pace_max_str = '10:00'
  640. form.auto_day = [0, 1, 2, 3, 4, 5, 6]
  641. form.notice_type = 'email'
  642. form.area = ''
  643. form.notes = ''
  644. autoFillText.value = ''
  645. }
  646. visible.value = true
  647. }
  648. const handleBeforeOk = async (done) => {
  649. try {
  650. ok_loading.value = true
  651. const { student_num, email, notice_type } = form
  652. if (!student_num || (!email && notice_type === 'email')) {
  653. Message.error('请填写完整的账号信息')
  654. return false
  655. }
  656. const sid = String(student_num || '').trim()
  657. if (!/^[A-Za-z0-9_-]{2,64}$/.test(sid)) {
  658. Message.error('学号/账号编码格式不正确(2–64 位字母数字下划线横线)')
  659. return false
  660. }
  661. const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
  662. if (notice_type === 'email' && !emailRegex.test(email)) {
  663. Message.error('请检查邮箱格式是否正确')
  664. return false
  665. }
  666. const pLo = parsePaceMinSecInput(form.pace_min_str)
  667. const pHi = parsePaceMinSecInput(form.pace_max_str)
  668. if (pLo == null || pHi == null) {
  669. Message.error('配速格式不正确,请使用 分:秒,如 5:30 或 10:00')
  670. return false
  671. }
  672. if (pLo < 180 || pLo > 600 || pHi < 180 || pHi > 600) {
  673. Message.error('配速须在 3:00–10:00/km 范围内')
  674. return false
  675. }
  676. if (pLo > pHi) {
  677. Message.error('配速下限不能慢于上限(左侧应更快,分:秒更小)')
  678. return false
  679. }
  680. if (form.auto_run) {
  681. const tk = Number(form.target_count)
  682. if (!Number.isFinite(tk) || tk < 0 || tk > 200) {
  683. Message.error('目标里程须在 0–200 公里之间')
  684. return false
  685. }
  686. const dMin = Number(form.auto_run_distance_min_km)
  687. const dMax = Number(form.auto_run_distance_max_km)
  688. if (
  689. !Number.isFinite(dMin) ||
  690. !Number.isFinite(dMax) ||
  691. dMin < 1 ||
  692. dMin > AUTO_SINGLE_RUN_MAX_KM ||
  693. dMax < 1 ||
  694. dMax > AUTO_SINGLE_RUN_MAX_KM
  695. ) {
  696. Message.error(`自动乐跑距离区间须在 1–${AUTO_SINGLE_RUN_MAX_KM} 公里之间`)
  697. return false
  698. }
  699. if (dMin > dMax) {
  700. Message.error('自动乐跑距离下限不能大于上限')
  701. return false
  702. }
  703. }
  704. const data = {
  705. id: form.id,
  706. student_num: form.student_num,
  707. email: form.email,
  708. area: form.area,
  709. auto_time: form.auto_time,
  710. auto_run: form.auto_run,
  711. notice_type: form.notice_type,
  712. target_count: form.target_count,
  713. auto_run_distance_min_km: form.auto_run_distance_min_km,
  714. auto_run_distance_max_km: form.auto_run_distance_max_km,
  715. auto_day: form.auto_day,
  716. notes: form.notes,
  717. pace_min_sec_per_km: pLo,
  718. pace_max_sec_per_km: pHi
  719. }
  720. const res = await addAccount(data)
  721. if (!res || res.code !== 0) {
  722. Notification.error({
  723. title: '保存乐跑账号失败!',
  724. content: res?.msg ?? '请稍后再试'
  725. })
  726. return false
  727. }
  728. Message.success('保存成功!')
  729. if (form.notice_type === 'bot' && res.data && !res.data.umo) {
  730. bindBotRef.value.openModal(res.data)
  731. }
  732. done()
  733. getAccountsAsync()
  734. } catch (error) {
  735. Notification.error({
  736. title: '保存乐跑账号失败!',
  737. content: error.message || '请稍后再试'
  738. })
  739. return false
  740. } finally {
  741. ok_loading.value = false
  742. }
  743. }
  744. const handleCancel = () => {
  745. visible.value = false
  746. }
  747. const getAccountsAsync = async () => {
  748. loading.value = true
  749. await getAccounts()
  750. loading.value = false
  751. }
  752. const getAccounts = async () => {
  753. try {
  754. const reqData = {
  755. ...queryData,
  756. pagesize: pagination.pagesize,
  757. current: pagination.current
  758. }
  759. const res = await accountList(reqData)
  760. if (!res || res.code !== 0)
  761. return Notification.error({
  762. title: '获取乐跑账号数据失败!',
  763. content: res?.msg ?? '请稍后再试'
  764. })
  765. data.value = res.data
  766. pagination.total = res.pagination.total
  767. } catch (error) {
  768. Notification.error({
  769. title: '获取乐跑账号数据失败!',
  770. content: error.message || '请稍后再试'
  771. })
  772. }
  773. }
  774. const GetNotice = async () => {
  775. const { path } = useRoute()
  776. const res = await getNotice(path)
  777. notice.value = res
  778. }
  779. const singleRunVisible = ref(false)
  780. const singleRunAccount = ref(null)
  781. const SingleRun = (item) => {
  782. if (item.state !== 1) {
  783. return Notification.warning({
  784. title: '当前乐跑账号需登录,请登录后再试',
  785. content: '如有疑问请联系哪吒乐跑客服'
  786. })
  787. }
  788. singleRunAccount.value = item
  789. singleRunVisible.value = true
  790. }
  791. const DeleteAccount = async (item) => {
  792. Modal.confirm({
  793. title: '解绑账号',
  794. content: () => h('div', [
  795. h('p', '您是否要解绑该账号?')
  796. ]),
  797. onOk: async () => {
  798. const res = await deleteAccount({ id: item.id })
  799. if (!res || res.code !== 0)
  800. return Notification.error({
  801. title: '解绑失败',
  802. content: res?.msg ?? '请稍后再试'
  803. })
  804. Message.success('解绑成功!')
  805. getAccounts()
  806. }
  807. })
  808. }
  809. const ChangeAutoRun = async (record) => {
  810. const oldValue = record.auto_run;
  811. record.auto_run = oldValue === 1 ? 0 : 1;
  812. try {
  813. const res = await changeAutoRun({ id: record.id });
  814. if (!res || res.code !== 0) {
  815. record.auto_run = oldValue;
  816. Notification.error({
  817. title: '切换自动乐跑状态失败!',
  818. content: res?.msg ?? '请稍后再试'
  819. })
  820. } else {
  821. Message.success(`自动乐跑状态已${record.auto_run === 1 ? '开启' : '关闭'}`)
  822. getAccounts()
  823. }
  824. } catch (error) {
  825. record.auto_run = oldValue;
  826. Message.error('切换自动乐跑状态失败!');
  827. }
  828. }
  829. const UpdateSelfAccount = async (record) => {
  830. try {
  831. const res = await updateSelfAccount({
  832. student_num: record.student_num
  833. })
  834. if (!res || res.code !== 0) {
  835. return Notification.error({
  836. title: '更新账号信息失败!',
  837. content: res?.msg ?? '请稍后再试'
  838. })
  839. }
  840. Message.success('更新账号信息成功!')
  841. getAccounts()
  842. } catch (error) {
  843. Notification.error({
  844. title: '更新账号信息失败!',
  845. content: error.message || '请稍后再试'
  846. })
  847. }
  848. }
  849. const stramptoTime = (time) => {
  850. return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  851. }
  852. let timer = null
  853. // 轮询
  854. const startPolling = () => {
  855. if (!timer) {
  856. timer = setInterval(async () => {
  857. await getAccounts()
  858. }, 5000)
  859. }
  860. }
  861. // 停止轮询
  862. const stopPolling = () => {
  863. if (timer) {
  864. clearInterval(timer)
  865. timer = null
  866. }
  867. }
  868. onMounted(async () => {
  869. queryData.queryTime = getSemesterTimestamps()
  870. queryDataForm.queryTime = getSemesterTimestamps()
  871. getAccountsAsync()
  872. GetNotice()
  873. startPolling()
  874. })
  875. // 组件销毁时停止轮询
  876. onUnmounted(() => {
  877. stopPolling()
  878. })
  879. </script>
  880. <style scoped lang="less">
  881. .container {
  882. padding: 0 20px 20px 20px;
  883. }
  884. .queryForm {
  885. margin-top: 20px;
  886. padding: 0 15px
  887. }
  888. .table {
  889. font-family: -apple-system, BlinkMacSystemFont;
  890. .state {
  891. display: flex;
  892. align-items: center;
  893. .circle {
  894. border-radius: 50%;
  895. height: 8px;
  896. min-height: 8px;
  897. width: 8px;
  898. min-width: 8px;
  899. margin-right: 5px;
  900. }
  901. .zero {
  902. background-color: rgb(var(--orange-6));
  903. }
  904. .one {
  905. background-color: rgb(var(--green-6));
  906. }
  907. .else {
  908. background-color: rgb(var(--red-6));
  909. }
  910. }
  911. }
  912. .custom-filter {
  913. padding: 20px;
  914. background: var(--color-bg-5);
  915. border: 1px solid var(--color-neutral-3);
  916. border-radius: var(--border-radius-medium);
  917. box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
  918. }
  919. .custom-filter-footer {
  920. display: flex;
  921. justify-content: space-between;
  922. }
  923. .vipcontent {
  924. display: flex;
  925. align-items: center;
  926. }
  927. .auto-fill-tip {
  928. color: rgb(var(--gray-6));
  929. }
  930. </style>