SchedulePanel.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. <template>
  2. <div class="schedule-panel">
  3. <div class="schedule-panel-header">
  4. <div class="schedule-panel-header-left">
  5. <span class="schedule-panel-title">定时任务</span>
  6. <span class="schedule-panel-desc">让大模型按计划自动执行任务,并把结果定时送达</span>
  7. </div>
  8. <div class="schedule-panel-close" @click="emit('close')">
  9. <div class="close-icon"></div>
  10. </div>
  11. </div>
  12. <div class="schedule-filter-bar" v-if="!loading">
  13. <a-input v-model:value="searchKeyword" placeholder="搜索定时任务..." allow-clear size="small" class="schedule-search-input" />
  14. <a-select v-model:value="filterStatus" size="small" class="schedule-filter-select">
  15. <a-select-option value="all">全部</a-select-option>
  16. <a-select-option value="enabled">已启用</a-select-option>
  17. <a-select-option value="disabled">未启用</a-select-option>
  18. </a-select>
  19. </div>
  20. <div class="schedule-summary" v-if="!loading">
  21. <span class="schedule-summary-label">定时任务</span>
  22. <span class="schedule-summary-count">{{ filteredScheduleList.length }}项</span>
  23. <a-button type="primary" class="schedule-create-btn" @click="handleCreate">
  24. <template #icon><PlusOutlined /></template>
  25. 创建
  26. </a-button>
  27. </div>
  28. <div class="schedule-list" v-if="!loading">
  29. <div v-for="item in filteredScheduleList" :key="item.id" class="schedule-item">
  30. <div class="schedule-item-header">
  31. <div class="schedule-item-name">
  32. <div class="schedule-item-icon"></div>
  33. <span>{{ item.name }}</span>
  34. </div>
  35. <div class="schedule-item-actions">
  36. <a-switch :checked="item.enabled" size="small" class="schedule-switch" @change="handleToggle(item)" />
  37. <div
  38. class="schedule-run-btn"
  39. @click.stop="handleRunNow(item)"
  40. :class="{ disabled: !item.enabled }"
  41. :title="item.enabled ? '立即执行' : '请先启用任务'"
  42. ></div>
  43. <div class="schedule-item-edit" @click.stop="handleEdit(item)" title="编辑"></div>
  44. <div class="schedule-item-delete" @click.stop="handleDelete(item)" title="删除"></div>
  45. </div>
  46. </div>
  47. <div class="schedule-item-info">
  48. <div class="schedule-item-prompt">{{ item.prompt }}</div>
  49. <div class="schedule-item-meta">
  50. <div class="schedule-item-cron">
  51. <span class="info-label">执行计划:</span>
  52. <span class="info-value">{{ item.cron_human || item.cron_expr }}</span>
  53. </div>
  54. <div v-if="item.next_run_at" class="schedule-item-next">
  55. <span class="info-label">下次执行:</span>
  56. <span class="info-value">{{ item.next_run_at }}</span>
  57. </div>
  58. </div>
  59. <div class="schedule-item-history-toggle" @click.stop="toggleHistory(item.id)">
  60. <span>{{ expandedIds.has(item.id) ? '收起历史' : '执行历史' }}</span>
  61. <div :class="['toggle-arrow', { expanded: expandedIds.has(item.id) }]"></div>
  62. <template v-if="historyStatsMap[item.id]">
  63. <span class="history-stats">
  64. <span class="stats-total">共{{ historyStatsMap[item.id].total }}次</span>
  65. <span class="stats-sep">·</span>
  66. <span class="stats-success">{{ historyStatsMap[item.id].success }}次成功</span>
  67. <span class="stats-sep">·</span>
  68. <span class="stats-failed">{{ historyStatsMap[item.id].failed }}次失败</span>
  69. </span>
  70. </template>
  71. </div>
  72. <div v-if="expandedIds.has(item.id)" class="schedule-history">
  73. <a-spin v-if="historyLoadingMap[item.id]" size="small" />
  74. <template v-else-if="historyMap[item.id]?.length">
  75. <div v-for="run in historyMap[item.id]" :key="run.id" class="history-item">
  76. <div class="history-item-header">
  77. <a-tag :color="runStatusColor(run.status)" class="history-status">{{ runStatusLabel(run.status) }}</a-tag>
  78. <span class="history-time">{{ run.started_at }}</span>
  79. <span v-if="run.duration_ms" class="history-duration">{{ (run.duration_ms / 1000).toFixed(0) }}s</span>
  80. </div>
  81. <div v-if="run.output" class="history-output">{{ run.output }}</div>
  82. <div v-if="run.error" class="history-error">{{ run.error }}</div>
  83. </div>
  84. </template>
  85. <div v-else class="history-empty">暂无执行记录</div>
  86. </div>
  87. </div>
  88. </div>
  89. <template v-if="filteredScheduleList.length === 0">
  90. <div v-if="searchKeyword" class="schedule-empty-hint">未找到匹配的任务</div>
  91. <a-empty v-else description="暂无定时任务" />
  92. </template>
  93. </div>
  94. <div class="schedule-panel-loading" v-else>
  95. <a-spin />
  96. </div>
  97. <CreateScheduleModal
  98. :visible="showCreateModal"
  99. :editData="editData"
  100. @close="showCreateModal = false"
  101. @created="refreshAndNotify"
  102. @updated="refreshAndNotify"
  103. />
  104. </div>
  105. </template>
  106. <script setup lang="ts">
  107. import { ref, reactive, computed, onMounted } from 'vue';
  108. import { PlusOutlined } from '@ant-design/icons-vue';
  109. import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '../../api';
  110. import { message, Modal } from 'ant-design-vue';
  111. import CreateScheduleModal from './CreateScheduleModal.vue';
  112. const emit = defineEmits<{ (e: 'close'): void; (e: 'open-session', sessionId: string, prompt: string): void; (e: 'data-changed'): void }>();
  113. interface ScheduleItem {
  114. id: number;
  115. name: string;
  116. cron_expr: string;
  117. cron_human: string;
  118. prompt: string;
  119. enabled: boolean;
  120. next_run_at: string | null;
  121. feedback_session_id?: string;
  122. schedule?: { freq: string; human?: string };
  123. }
  124. const scheduleList = ref<ScheduleItem[]>([]);
  125. const loading = ref(false);
  126. const searchKeyword = ref('');
  127. const filterStatus = ref<'all' | 'enabled' | 'disabled'>('all');
  128. const filteredScheduleList = computed(() => {
  129. return scheduleList.value.filter((item) => {
  130. const keyword = searchKeyword.value.trim().toLowerCase();
  131. const matchKeyword = !keyword || item.name.toLowerCase().includes(keyword) || item.prompt.toLowerCase().includes(keyword);
  132. const matchStatus = filterStatus.value === 'all' || (filterStatus.value === 'enabled' ? item.enabled : !item.enabled);
  133. return matchKeyword && matchStatus;
  134. });
  135. });
  136. const showCreateModal = ref(false);
  137. const editData = ref<ScheduleItem | null>(null);
  138. const expandedIds = ref(new Set<number>());
  139. const historyMap = reactive<Record<number, any[]>>({});
  140. const historyLoadingMap = reactive<Record<number, boolean>>({});
  141. const historyStatsMap = reactive<Record<number, { total: number; success: number; failed: number }>>({});
  142. const fetchScheduleList = async () => {
  143. loading.value = true;
  144. try {
  145. const res = await getScheduleList();
  146. scheduleList.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
  147. } catch {
  148. scheduleList.value = [];
  149. } finally {
  150. loading.value = false;
  151. }
  152. };
  153. const refreshAndNotify = async () => {
  154. await fetchScheduleList();
  155. emit('data-changed');
  156. };
  157. const handleCreate = () => {
  158. editData.value = null;
  159. showCreateModal.value = true;
  160. };
  161. const handleEdit = (item: ScheduleItem) => {
  162. editData.value = item;
  163. showCreateModal.value = true;
  164. };
  165. const handleToggle = async (item: ScheduleItem) => {
  166. try {
  167. if (item.enabled) {
  168. await pauseSchedule(item.id);
  169. message.success(`已暂停任务「${item.name}」`);
  170. } else {
  171. await resumeSchedule(item.id);
  172. message.success(`已恢复任务「${item.name}」`);
  173. }
  174. item.enabled = !item.enabled;
  175. emit('data-changed');
  176. } catch {
  177. message.error('操作失败');
  178. }
  179. };
  180. const handleDelete = (item: ScheduleItem) => {
  181. Modal.confirm({
  182. title: '提示',
  183. content: `是否删除定时任务「${item.name}」?`,
  184. okText: '确认',
  185. cancelText: '取消',
  186. wrapClassName: 'delete-task-confirm-modal',
  187. onOk: async () => {
  188. try {
  189. await deleteSchedule(item.id);
  190. message.success('删除成功');
  191. refreshAndNotify();
  192. } catch {
  193. message.error('删除失败');
  194. }
  195. },
  196. });
  197. };
  198. const handleRunNow = async (item: ScheduleItem) => {
  199. if (!item.enabled) return;
  200. if (item.feedback_session_id) {
  201. emit('open-session', item.feedback_session_id, item.prompt);
  202. } else {
  203. message.warning('该任务暂无关联会话');
  204. }
  205. };
  206. const toggleHistory = async (taskId: number) => {
  207. if (expandedIds.value.has(taskId)) {
  208. expandedIds.value.delete(taskId);
  209. return;
  210. }
  211. expandedIds.value.add(taskId);
  212. if (!historyMap[taskId]) {
  213. historyLoadingMap[taskId] = true;
  214. try {
  215. const res = await getScheduleRuns(taskId);
  216. const runs = res?.runs || (Array.isArray(res) ? res : []);
  217. historyMap[taskId] = runs;
  218. historyStatsMap[taskId] = {
  219. total: res?.total ?? runs.length,
  220. success: runs.filter((r: any) => r.status === 'success').length,
  221. failed: runs.filter((r: any) => r.status === 'failed').length,
  222. };
  223. } catch {
  224. historyMap[taskId] = [];
  225. } finally {
  226. historyLoadingMap[taskId] = false;
  227. }
  228. }
  229. };
  230. const runStatusLabel = (status: string) => {
  231. const map: Record<string, string> = { running: '执行中', success: '成功', failed: '失败', skipped: '跳过' };
  232. return map[status] || status;
  233. };
  234. const runStatusColor = (status: string) => {
  235. const map: Record<string, string> = { running: 'blue', success: 'green', failed: 'red', skipped: 'default' };
  236. return map[status] || 'default';
  237. };
  238. defineExpose({ fetchScheduleList });
  239. onMounted(() => {
  240. fetchScheduleList();
  241. });
  242. </script>
  243. <style scoped lang="less">
  244. .schedule-panel {
  245. height: 100%;
  246. display: flex;
  247. flex-direction: column;
  248. overflow: hidden;
  249. background-image: var(--img-chat-panel-bg);
  250. background-repeat: no-repeat;
  251. background-size: 100% 100%;
  252. border-radius: 4px;
  253. }
  254. .schedule-panel-header {
  255. display: flex;
  256. align-items: center;
  257. justify-content: space-between;
  258. padding: 14px 20px;
  259. border-bottom: 1px solid rgba(63, 80, 106, 0.25);
  260. flex-shrink: 0;
  261. .schedule-panel-header-left {
  262. display: flex;
  263. align-items: baseline;
  264. gap: 10px;
  265. min-width: 0;
  266. flex: 1;
  267. }
  268. .schedule-panel-title {
  269. font-size: 16px;
  270. font-weight: 600;
  271. color: #e8edf3;
  272. letter-spacing: 1px;
  273. flex-shrink: 0;
  274. }
  275. .schedule-panel-desc {
  276. font-size: 12px;
  277. color: #6a7a8a;
  278. overflow: hidden;
  279. text-overflow: ellipsis;
  280. white-space: nowrap;
  281. }
  282. .schedule-panel-close {
  283. width: 28px;
  284. height: 28px;
  285. display: flex;
  286. align-items: center;
  287. justify-content: center;
  288. cursor: pointer;
  289. border-radius: 4px;
  290. transition: background 0.2s;
  291. &:hover {
  292. background: rgba(255, 77, 79, 0.15);
  293. }
  294. .close-icon {
  295. width: 16px;
  296. height: 16px;
  297. background-image: var(--img-chat-close-icon-btn);
  298. background-repeat: no-repeat;
  299. background-size: 100% 100%;
  300. }
  301. }
  302. }
  303. .schedule-filter-bar {
  304. display: flex;
  305. gap: 12px;
  306. margin: 16px 20px 0;
  307. flex-shrink: 0;
  308. .schedule-search-input {
  309. flex: 1;
  310. height: 36px;
  311. background: rgba(10, 132, 255, 0.08);
  312. border: 1px solid rgba(63, 80, 106, 0.4);
  313. border-radius: 6px;
  314. color: #e0e6ed;
  315. transition: all 0.3s;
  316. &:hover,
  317. &:focus {
  318. border-color: rgba(10, 132, 255, 0.5);
  319. background: rgba(10, 132, 255, 0.12);
  320. }
  321. :deep(.zxm-input) {
  322. background: transparent;
  323. color: #e0e6ed;
  324. font-size: 13px;
  325. }
  326. }
  327. .schedule-filter-select {
  328. width: 130px;
  329. height: 36px;
  330. :deep(.zxm-select-selector) {
  331. background: rgba(10, 132, 255, 0.08) !important;
  332. border: 1px solid rgba(63, 80, 106, 0.4) !important;
  333. border-radius: 6px !important;
  334. color: #e0e6ed;
  335. height: 36px;
  336. }
  337. :deep(.zxm-select-selection-item) {
  338. line-height: 34px !important;
  339. }
  340. }
  341. }
  342. .schedule-summary {
  343. display: flex;
  344. align-items: center;
  345. gap: 6px;
  346. padding: 12px 20px 10px;
  347. flex-shrink: 0;
  348. font-size: 13px;
  349. .schedule-summary-label {
  350. color: #e8edf3;
  351. font-weight: 500;
  352. }
  353. .schedule-summary-count {
  354. color: #6a7a8a;
  355. }
  356. .schedule-create-btn {
  357. flex-shrink: 0;
  358. height: 32px;
  359. border-radius: 6px;
  360. font-size: 13px;
  361. display: flex;
  362. align-items: center;
  363. gap: 4px;
  364. margin-left: auto;
  365. }
  366. }
  367. .schedule-panel-loading {
  368. flex: 1;
  369. display: flex;
  370. justify-content: center;
  371. align-items: center;
  372. padding: 80px 0;
  373. }
  374. /* 任务列表 */
  375. .schedule-list {
  376. flex: 1;
  377. overflow-y: auto;
  378. padding: 12px 20px 20px;
  379. &::-webkit-scrollbar {
  380. width: 6px;
  381. }
  382. &::-webkit-scrollbar-track {
  383. background: transparent;
  384. }
  385. &::-webkit-scrollbar-thumb {
  386. background: rgba(63, 80, 106, 0.4);
  387. border-radius: 3px;
  388. &:hover {
  389. background: rgba(63, 80, 106, 0.6);
  390. }
  391. }
  392. }
  393. .schedule-item {
  394. padding: 16px 18px;
  395. margin-bottom: 8px;
  396. border-radius: 8px;
  397. border: 1px solid rgba(63, 80, 106, 0.15);
  398. background: rgba(10, 132, 255, 0.03);
  399. transition: all 0.25s ease;
  400. &:hover {
  401. background: rgba(10, 132, 255, 0.08);
  402. border-color: rgba(10, 132, 255, 0.25);
  403. }
  404. &:last-child {
  405. margin-bottom: 0;
  406. }
  407. }
  408. .schedule-item-header {
  409. display: flex;
  410. align-items: center;
  411. justify-content: space-between;
  412. margin-bottom: 10px;
  413. }
  414. .schedule-item-name {
  415. display: flex;
  416. align-items: center;
  417. gap: 10px;
  418. font-size: 15px;
  419. font-weight: 600;
  420. color: #e8edf3;
  421. }
  422. .schedule-item-icon {
  423. width: 20px;
  424. height: 20px;
  425. background-image: var(--img-chat-options-schedule-icon);
  426. background-repeat: no-repeat;
  427. background-size: 100% 100%;
  428. background-position: center;
  429. opacity: 0.8;
  430. }
  431. .schedule-item-actions {
  432. display: flex;
  433. align-items: center;
  434. gap: 10px;
  435. }
  436. .schedule-item-edit {
  437. width: 24px;
  438. height: 24px;
  439. cursor: pointer;
  440. border-radius: 4px;
  441. display: flex;
  442. align-items: center;
  443. justify-content: center;
  444. transition: background 0.2s;
  445. background-image: var(--img-chat-edit-icon);
  446. background-repeat: no-repeat;
  447. background-size: 14px 14px;
  448. background-position: center;
  449. &:hover {
  450. background-color: rgba(10, 132, 255, 0.15);
  451. }
  452. }
  453. .schedule-item-delete {
  454. width: 24px;
  455. height: 24px;
  456. cursor: pointer;
  457. border-radius: 4px;
  458. display: flex;
  459. align-items: center;
  460. justify-content: center;
  461. transition: background 0.2s;
  462. background-image: var(--img-chat-delete-icon);
  463. background-repeat: no-repeat;
  464. background-size: 14px 14px;
  465. background-position: center;
  466. &:hover {
  467. background-color: rgba(255, 77, 79, 0.15);
  468. }
  469. }
  470. .schedule-item-info {
  471. display: flex;
  472. flex-direction: column;
  473. gap: 6px;
  474. padding-left: 30px;
  475. }
  476. .schedule-item-meta {
  477. display: flex;
  478. align-items: center;
  479. gap: 20px;
  480. font-size: 13px;
  481. }
  482. .schedule-item-cron,
  483. .schedule-item-next {
  484. .info-label {
  485. color: #6a7a8a;
  486. }
  487. .info-value {
  488. color: #5cb8ff;
  489. }
  490. }
  491. .schedule-item-prompt {
  492. font-size: 13px;
  493. color: #8b949e;
  494. line-height: 1.6;
  495. overflow: hidden;
  496. text-overflow: ellipsis;
  497. white-space: nowrap;
  498. }
  499. .schedule-switch {
  500. :deep(.zxm-switch) {
  501. min-width: 36px;
  502. height: 20px;
  503. background: rgba(63, 80, 106, 0.5);
  504. &::after {
  505. width: 16px;
  506. height: 16px;
  507. top: 2px;
  508. left: 2px;
  509. border-radius: 50%;
  510. background: #fff;
  511. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  512. transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  513. }
  514. &.zxm-switch-checked {
  515. background: rgba(10, 132, 255, 0.6);
  516. &::after {
  517. left: calc(100% - 18px);
  518. }
  519. }
  520. }
  521. }
  522. .schedule-run-btn {
  523. width: 24px;
  524. height: 24px;
  525. cursor: pointer;
  526. border-radius: 4px;
  527. background-image: var(--img-chat-schedule-run-icon);
  528. background-repeat: no-repeat;
  529. background-size: 14px 14px;
  530. background-position: center;
  531. transition: background-color 0.2s;
  532. &:hover {
  533. background-color: rgba(10, 132, 255, 0.15);
  534. }
  535. &.disabled {
  536. opacity: 0.4;
  537. cursor: not-allowed;
  538. pointer-events: none;
  539. }
  540. }
  541. .schedule-item-history-toggle {
  542. display: flex;
  543. align-items: center;
  544. gap: 4px;
  545. font-size: 12px;
  546. color: #5cb8ff;
  547. cursor: pointer;
  548. margin-top: 4px;
  549. transition: opacity 0.2s;
  550. width: fit-content;
  551. &:hover {
  552. opacity: 0.8;
  553. }
  554. .toggle-arrow {
  555. width: 0;
  556. height: 0;
  557. border-left: 4px solid transparent;
  558. border-right: 4px solid transparent;
  559. border-top: 5px solid #5cb8ff;
  560. transition: transform 0.2s;
  561. &.expanded {
  562. transform: rotate(180deg);
  563. }
  564. }
  565. .history-stats {
  566. display: inline-flex;
  567. align-items: center;
  568. gap: 0;
  569. margin-left: 6px;
  570. font-size: 11px;
  571. line-height: 1.5;
  572. .stats-total {
  573. color: #8b949e;
  574. }
  575. .stats-sep {
  576. color: #4a5568;
  577. margin: 0 4px;
  578. }
  579. .stats-success {
  580. color: #52c41a;
  581. }
  582. .stats-failed {
  583. color: #ff7875;
  584. }
  585. }
  586. }
  587. .schedule-history {
  588. margin-top: 10px;
  589. padding: 12px;
  590. background: rgba(0, 0, 0, 0.15);
  591. border: 1px solid rgba(63, 80, 106, 0.18);
  592. border-radius: 6px;
  593. display: flex;
  594. flex-direction: column;
  595. gap: 10px;
  596. max-height: 170px;
  597. overflow-y: auto;
  598. &::-webkit-scrollbar {
  599. width: 4px;
  600. }
  601. &::-webkit-scrollbar-thumb {
  602. background: rgba(63, 80, 106, 0.4);
  603. border-radius: 2px;
  604. }
  605. }
  606. .history-item {
  607. padding: 10px 12px;
  608. background: rgba(10, 132, 255, 0.04);
  609. border: 1px solid rgba(63, 80, 106, 0.15);
  610. border-radius: 6px;
  611. }
  612. .history-item-header {
  613. display: flex;
  614. align-items: center;
  615. gap: 8px;
  616. margin-bottom: 6px;
  617. .history-status {
  618. font-size: 12px;
  619. }
  620. .history-time {
  621. font-size: 12px;
  622. color: #6a7a8a;
  623. }
  624. .history-duration {
  625. font-size: 12px;
  626. color: #8b949e;
  627. margin-left: auto;
  628. }
  629. }
  630. .history-output {
  631. font-size: 12px;
  632. color: #c9d1d9;
  633. line-height: 1.6;
  634. max-height: 60px;
  635. overflow: hidden;
  636. text-overflow: ellipsis;
  637. display: -webkit-box;
  638. -webkit-line-clamp: 3;
  639. -webkit-box-orient: vertical;
  640. }
  641. .history-error {
  642. font-size: 12px;
  643. color: #ff7875;
  644. line-height: 1.6;
  645. }
  646. .history-empty {
  647. font-size: 12px;
  648. color: #6a7a8a;
  649. text-align: center;
  650. padding: 8px 0;
  651. }
  652. .schedule-empty-hint {
  653. text-align: center;
  654. color: #8b949e;
  655. font-size: 13px;
  656. padding: 20px 0;
  657. }
  658. </style>