TaskListPanel.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. <template>
  2. <div class="task-list-panel">
  3. <!-- 操作区域(顶部) -->
  4. <div class="options-area">
  5. <div v-for="item in optionItems" :key="item.type" :class="['options-area-item', `options-${item.type}`]" @click="handleOptionClick(item.type)">
  6. <div class="options-img">
  7. <div class="options-icon"></div>
  8. </div>
  9. <a-button size="small" type="primary">{{ item.label }}</a-button>
  10. </div>
  11. </div>
  12. <!-- 搜索模式 -->
  13. <div class="search-header">
  14. <a-input v-model:value="searchKeyword" placeholder="搜索任务名称..." allow-clear size="small" class="search-input" />
  15. </div>
  16. <!-- 置顶任务区域 -->
  17. <div v-if="pinnedTaskList.length > 0" class="pinned-task-list">
  18. <div
  19. v-for="(task, index) in pinnedTaskList"
  20. :key="'pinned-' + task.id"
  21. :class="['task-item', { active: currentTaskId === task.id }]"
  22. @click="emit('switch-task', task.id)"
  23. >
  24. <div class="task-pin-icon pinned" @click.stop="togglePin(task)" title="取消置顶"></div>
  25. <span class="task-name">{{ task.name }}</span>
  26. <div class="task-right">
  27. <span v-if="task.isStreaming" class="task-loading"></span>
  28. <span v-else class="task-time">{{ formatRelativeTime(task.updatedAt) }}</span>
  29. <div class="task-delete" @click.stop="emit('delete-task', task, index)" title="删除"></div>
  30. </div>
  31. </div>
  32. </div>
  33. <div class="task-list">
  34. <div
  35. v-for="(task, index) in filteredTaskList"
  36. :key="index"
  37. :class="['task-item', { active: currentTaskId === task.id }]"
  38. @click="emit('switch-task', task.id)"
  39. >
  40. <div class="task-pin-icon" @click.stop="togglePin(task)" title="置顶"></div>
  41. <span class="task-name">{{ task.name }}</span>
  42. <div class="task-right">
  43. <span v-if="task.isStreaming" class="task-loading"></span>
  44. <span v-else class="task-time">{{ formatRelativeTime(task.updatedAt) }}</span>
  45. <div class="task-delete" @click.stop="emit('delete-task', task, index)" title="删除"></div>
  46. </div>
  47. </div>
  48. <div v-if="searchKeyword && filteredTaskList.length === 0" class="empty-hint"> 未找到匹配的任务 </div>
  49. <div v-else-if="filteredTaskList.length === 0" class="empty-hint"> 还没有任务 </div>
  50. </div>
  51. </div>
  52. </template>
  53. <script setup lang="ts">
  54. import { ref, computed } from 'vue';
  55. import type { Task } from './types';
  56. const props = defineProps<{
  57. taskList: Task[];
  58. currentTaskId: string;
  59. }>();
  60. const emit = defineEmits<{
  61. (e: 'switch-task', taskId: string): void;
  62. (e: 'create-task'): void;
  63. (e: 'delete-task', task: Task, index: number): void;
  64. (e: 'pin-task', task: Task): void;
  65. (e: 'toggle-skill'): void;
  66. (e: 'toggle-schedules'): void;
  67. (e: 'toggle-sub-agents'): void;
  68. }>();
  69. type OptionType = 'add' | 'search' | 'skill' | 'schedules' | 'sub-agents';
  70. const activeOption = ref<OptionType>('add');
  71. const searchKeyword = ref('');
  72. const pinnedTaskList = computed(() => {
  73. let list = props.taskList.filter((task) => task.sortOrder === 1);
  74. if (searchKeyword.value.trim()) {
  75. const keyword = searchKeyword.value.trim().toLowerCase();
  76. list = list.filter((task) => task.name.toLowerCase().includes(keyword));
  77. }
  78. return list;
  79. });
  80. const filteredTaskList = computed(() => {
  81. let list = props.taskList.filter((task) => task.sortOrder !== 1);
  82. if (searchKeyword.value.trim()) {
  83. const keyword = searchKeyword.value.trim().toLowerCase();
  84. list = list.filter((task) => task.name.toLowerCase().includes(keyword));
  85. }
  86. return list;
  87. });
  88. const togglePin = (task: Task) => {
  89. emit('pin-task', task);
  90. };
  91. const optionItems = [
  92. { type: 'add' as OptionType, label: '新建任务' },
  93. // { type: 'search' as OptionType, label: '搜索' },
  94. { type: 'skill' as OptionType, label: '技能' },
  95. { type: 'sub-agents' as OptionType, label: '子智能体' },
  96. { type: 'schedules' as OptionType, label: '定时任务' },
  97. ];
  98. const formatRelativeTime = (dateStr?: string) => {
  99. if (!dateStr) return '刚刚';
  100. const now = Date.now();
  101. const time = new Date(dateStr).getTime();
  102. const diff = now - time;
  103. if (diff < 60 * 1000) return '刚刚';
  104. if (diff < 60 * 60 * 1000) return `${Math.floor(diff / (60 * 1000))}分钟前`;
  105. if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / (60 * 60 * 1000))}小时前`;
  106. return `${Math.floor(diff / (24 * 60 * 60 * 1000))}天前`;
  107. };
  108. const handleOptionClick = (type: OptionType) => {
  109. if (type === 'add') {
  110. emit('create-task');
  111. } else if (type === 'skill') {
  112. emit('toggle-skill');
  113. } else if (type === 'sub-agents') {
  114. emit('toggle-sub-agents');
  115. } else if (type === 'schedules') {
  116. emit('toggle-schedules');
  117. } else {
  118. activeOption.value = type;
  119. searchKeyword.value = '';
  120. }
  121. };
  122. </script>
  123. <style scoped lang="less">
  124. .task-list-panel {
  125. width: 100%;
  126. height: 100%;
  127. display: flex;
  128. flex-direction: column;
  129. background-image: var(--img-chat-task-panel-bg);
  130. background-repeat: no-repeat;
  131. background-size: 100% 100%;
  132. border-radius: 8px;
  133. flex-shrink: 0;
  134. padding: 10px;
  135. .search-header {
  136. padding-bottom: 10px;
  137. .search-input {
  138. height: 32px;
  139. background: rgba(10, 132, 255, 0.1);
  140. border: 1px solid rgba(63, 80, 106, 0.5);
  141. color: #e0e6ed;
  142. :deep(.zxm-input) {
  143. background: transparent;
  144. color: #e0e6ed;
  145. }
  146. }
  147. }
  148. .pinned-task-list {
  149. max-height: 180px;
  150. overflow-y: auto;
  151. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  152. margin-bottom: 8px;
  153. padding-bottom: 8px;
  154. }
  155. .task-list {
  156. flex: 1;
  157. overflow-y: auto;
  158. }
  159. .pinned-task-list,
  160. .task-list {
  161. .task-item {
  162. height: 36px;
  163. display: flex;
  164. align-items: center;
  165. gap: 4px;
  166. padding: 4px;
  167. cursor: pointer;
  168. transition: all 0.3s;
  169. margin-bottom: 4px;
  170. color: #e0e6ed;
  171. &:hover {
  172. background: rgba(10, 132, 255, 0.1);
  173. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  174. }
  175. &.active {
  176. background: transparent;
  177. box-shadow: inset 0 0 8px rgba(10, 132, 255, 0.4);
  178. border: 1px solid rgba(10, 132, 255, 0.3);
  179. color: #e0e6ed;
  180. }
  181. .task-pin-icon {
  182. width: 28px;
  183. height: 28px;
  184. flex-shrink: 0;
  185. background-image: var(--img-chat-pin-icon);
  186. background-repeat: no-repeat;
  187. background-size: 16px 16px;
  188. background-position: center;
  189. opacity: 0;
  190. transition: opacity 0.2s;
  191. border-radius: 4px;
  192. &.pinned {
  193. opacity: 1;
  194. }
  195. &:hover {
  196. background-color: rgba(10, 132, 255, 0.2);
  197. }
  198. }
  199. &:hover .task-pin-icon {
  200. opacity: 1;
  201. }
  202. .task-icon {
  203. width: 16px;
  204. height: 16px;
  205. flex-shrink: 0;
  206. background-image: var(--img-chat-task-add-icon);
  207. background-repeat: no-repeat;
  208. background-size: 100% 100%;
  209. }
  210. .task-name {
  211. font-size: 14px;
  212. font-weight: 400;
  213. line-height: 1.4;
  214. overflow: hidden;
  215. text-overflow: ellipsis;
  216. white-space: nowrap;
  217. flex: 1;
  218. }
  219. .task-right {
  220. display: flex;
  221. align-items: center;
  222. gap: 6px;
  223. flex-shrink: 0;
  224. }
  225. .task-time {
  226. font-size: 12px;
  227. color: #8b949e;
  228. flex-shrink: 0;
  229. white-space: nowrap;
  230. }
  231. .task-loading {
  232. width: 14px;
  233. height: 14px;
  234. flex-shrink: 0;
  235. border: 2px solid rgba(10, 132, 255, 0.2);
  236. border-top-color: rgba(10, 132, 255, 0.8);
  237. border-radius: 50%;
  238. animation: task-spin 0.8s linear infinite;
  239. }
  240. .task-delete {
  241. flex-shrink: 0;
  242. width: 28px;
  243. height: 28px;
  244. display: none;
  245. background-image: var(--img-chat-delete-icon);
  246. background-repeat: no-repeat;
  247. background-size: 16px 16px;
  248. background-position: center;
  249. transition: all 0.2s;
  250. border-radius: 4px;
  251. &:hover {
  252. background-color: rgba(10, 132, 255, 0.2);
  253. }
  254. }
  255. &:hover .task-time,
  256. &:hover .task-loading {
  257. display: none;
  258. }
  259. &:hover .task-delete {
  260. display: flex;
  261. }
  262. }
  263. .empty-hint {
  264. text-align: center;
  265. color: #8b949e;
  266. font-size: 13px;
  267. padding: 20px 0;
  268. }
  269. }
  270. @keyframes task-spin {
  271. to {
  272. transform: rotate(360deg);
  273. }
  274. }
  275. .options-area {
  276. display: flex;
  277. flex-direction: column;
  278. gap: 4px;
  279. padding-bottom: 10px;
  280. border-bottom: 1px solid rgba(63, 80, 106, 0.3);
  281. .options-area-item {
  282. display: flex;
  283. flex-direction: row;
  284. align-items: center;
  285. gap: 10px;
  286. cursor: pointer;
  287. border-radius: 6px;
  288. padding: 6px 8px;
  289. transition: all 0.3s;
  290. &:hover {
  291. background: rgba(10, 132, 255, 0.1);
  292. }
  293. .options-img {
  294. width: 32px;
  295. height: 32px;
  296. display: flex;
  297. align-items: center;
  298. justify-content: center;
  299. flex-shrink: 0;
  300. background-image: var(--img-chat-options-icon-border);
  301. background-repeat: no-repeat;
  302. background-size: 100% 100%;
  303. background-position: center;
  304. }
  305. .options-icon {
  306. width: 14px;
  307. height: 14px;
  308. background-repeat: no-repeat;
  309. background-size: 100% 100%;
  310. background-position: center;
  311. }
  312. &.options-add .options-icon {
  313. background-image: var(--img-chat-options-add-icon);
  314. }
  315. &.options-search .options-icon {
  316. background-image: var(--img-chat-options-search-icon);
  317. }
  318. &.options-skill .options-icon {
  319. background-image: var(--img-chat-options-skill-icon);
  320. }
  321. &.options-schedules .options-icon {
  322. background-image: var(--img-chat-options-schedule-icon);
  323. }
  324. &.options-sub-agents .options-icon {
  325. background-image: var(--img-chat-options-sub-agent-icon);
  326. }
  327. .zxm-btn {
  328. font-size: 14px;
  329. padding: 0;
  330. height: auto;
  331. background: transparent !important;
  332. border: none;
  333. color: #e0e6ed;
  334. box-shadow: none;
  335. }
  336. }
  337. }
  338. }
  339. </style>