operation-logs-modal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. 'use client'
  2. import type { FC } from 'react'
  3. import { useState } from 'react'
  4. import {
  5. RiFileHistoryLine,
  6. RiFilterLine,
  7. RiRefreshLine,
  8. } from '@remixicon/react'
  9. import Modal from '@/app/components/base/modal'
  10. import Button from '@/app/components/base/button'
  11. import { DocumentOperationType } from '@/models/operation-log'
  12. import type { OperationLog, OperationLogQueryParams } from '@/models/operation-log'
  13. import { useDocumentOperationLogs } from '@/service/knowledge/use-operation-log'
  14. import Loading from '@/app/components/base/loading'
  15. type OperationLogsModalProps = {
  16. isOpen: boolean
  17. onClose: () => void
  18. datasetId: string
  19. documentId: string
  20. documentName: string
  21. }
  22. const OperationLogsModal: FC<OperationLogsModalProps> = ({
  23. isOpen,
  24. onClose,
  25. datasetId,
  26. documentId,
  27. documentName,
  28. }) => {
  29. const [params, setParams] = useState<OperationLogQueryParams>({
  30. page: 1,
  31. per_page: 20,
  32. })
  33. const { data, isLoading, refetch } = useDocumentOperationLogs(
  34. datasetId,
  35. documentId,
  36. params,
  37. )
  38. const [actionFilter, setActionFilter] = useState<string>('')
  39. const handleFilterByAction = (action: string) => {
  40. const newAction = action === actionFilter ? '' : action
  41. setActionFilter(newAction)
  42. setParams({
  43. ...params,
  44. action: newAction || undefined,
  45. page: 1,
  46. })
  47. }
  48. const handlePageChange = (page: number) => {
  49. setParams({ ...params, page })
  50. }
  51. const getOperationTypeLabel = (action: string) => {
  52. const labels: Record<string, string> = {
  53. [DocumentOperationType.DOCUMENT_ENABLE]: '启用',
  54. [DocumentOperationType.DOCUMENT_DISABLE]: '禁用',
  55. [DocumentOperationType.DOCUMENT_ARCHIVE]: '归档',
  56. [DocumentOperationType.DOCUMENT_UNARCHIVE]: '取消归档',
  57. [DocumentOperationType.DOCUMENT_BATCH_ENABLE]: '批量启用',
  58. [DocumentOperationType.DOCUMENT_BATCH_DISABLE]: '批量禁用',
  59. [DocumentOperationType.DOCUMENT_BATCH_ARCHIVE]: '批量归档',
  60. [DocumentOperationType.DOCUMENT_BATCH_UNARCHIVE]: '批量取消归档',
  61. [DocumentOperationType.DOCUMENT_SCHEDULE_CREATE]: '创建定时任务',
  62. [DocumentOperationType.DOCUMENT_SCHEDULE_CANCEL]: '取消定时任务',
  63. [DocumentOperationType.DOCUMENT_SCHEDULE_EXECUTE]: '执行定时任务',
  64. [DocumentOperationType.DOCUMENT_CREATE]: '创建',
  65. [DocumentOperationType.DOCUMENT_UPDATE]: '更新',
  66. [DocumentOperationType.DOCUMENT_DELETE]: '删除',
  67. [DocumentOperationType.DOCUMENT_RENAME]: '重命名',
  68. }
  69. return labels[action] || action
  70. }
  71. const getOperationTypeBadge = (action: string) => {
  72. const config: Record<string, { bg: string; text: string }> = {
  73. [DocumentOperationType.DOCUMENT_ENABLE]: { bg: 'bg-util-colors-teal-teal-100', text: 'text-util-colors-teal-teal-600' },
  74. [DocumentOperationType.DOCUMENT_DISABLE]: { bg: 'bg-util-colors-orange-orange-100', text: 'text-util-colors-orange-orange-600' },
  75. [DocumentOperationType.DOCUMENT_ARCHIVE]: { bg: 'bg-util-colors-gray-gray-100', text: 'text-util-colors-gray-gray-600' },
  76. [DocumentOperationType.DOCUMENT_SCHEDULE_CREATE]: { bg: 'bg-util-colors-blue-blue-100', text: 'text-util-colors-blue-blue-600' },
  77. [DocumentOperationType.DOCUMENT_SCHEDULE_EXECUTE]: { bg: 'bg-util-colors-purple-purple-100', text: 'text-util-colors-purple-purple-600' },
  78. }
  79. const style = config[action] || { bg: 'bg-util-colors-gray-gray-100', text: 'text-util-colors-gray-gray-600' }
  80. return (
  81. <span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${style.bg} ${style.text}`}>
  82. {getOperationTypeLabel(action)}
  83. </span>
  84. )
  85. }
  86. const renderChangesSummary = (log: OperationLog) => {
  87. const { changes, metadata } = log.content
  88. if (!changes && !metadata)
  89. return null
  90. return (
  91. <div className="mt-2 space-y-1 text-xs">
  92. {changes && (
  93. <div className="flex gap-4">
  94. {changes.old && (
  95. <div className="flex-1">
  96. <span className="text-text-quaternary">修改前:</span>
  97. <code className="ml-1 px-1 py-0.5 bg-background-section-burn rounded text-text-tertiary">
  98. {JSON.stringify(changes.old)}
  99. </code>
  100. </div>
  101. )}
  102. {changes.new && (
  103. <div className="flex-1">
  104. <span className="text-text-quaternary">修改后:</span>
  105. <code className="ml-1 px-1 py-0.5 bg-background-section-burn rounded text-text-tertiary">
  106. {JSON.stringify(changes.new)}
  107. </code>
  108. </div>
  109. )}
  110. </div>
  111. )}
  112. {metadata?.scheduled && (
  113. <div className="text-text-quaternary">
  114. <span className="inline-flex items-center gap-1">
  115. <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
  116. <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
  117. </svg>
  118. 定时执行
  119. </span>
  120. {metadata.schedule_id && (
  121. <span className="ml-2 text-text-quaternary">
  122. ID: {metadata.schedule_id.substring(0, 16)}...
  123. </span>
  124. )}
  125. </div>
  126. )}
  127. {metadata?.batch && (
  128. <div className="text-text-quaternary">
  129. 批量操作: {metadata.batch_size} 个文档
  130. </div>
  131. )}
  132. </div>
  133. )
  134. }
  135. const logs = data?.logs || []
  136. const hasLogs = logs.length > 0
  137. // Quick action filters
  138. const quickFilters = [
  139. DocumentOperationType.DOCUMENT_ENABLE,
  140. DocumentOperationType.DOCUMENT_DISABLE,
  141. DocumentOperationType.DOCUMENT_ARCHIVE,
  142. DocumentOperationType.DOCUMENT_SCHEDULE_CREATE,
  143. DocumentOperationType.DOCUMENT_SCHEDULE_EXECUTE,
  144. ]
  145. return (
  146. <Modal
  147. isShow={isOpen}
  148. onClose={onClose}
  149. title={
  150. <div className="flex items-center gap-2">
  151. <RiFileHistoryLine className="w-5 h-5" />
  152. <span>操作历史</span>
  153. </div>
  154. }
  155. className="!w-[800px] !max-w-[90vw]"
  156. >
  157. <div className="py-4">
  158. {/* Document Name */}
  159. <div className="mb-4">
  160. <div className="text-sm text-text-tertiary">
  161. 文档: <span className="text-text-secondary font-medium">{documentName}</span>
  162. </div>
  163. </div>
  164. {/* Toolbar */}
  165. <div className="mb-4 space-y-3">
  166. {/* Quick Filters */}
  167. <div className="flex items-center gap-2 flex-wrap">
  168. <RiFilterLine className="w-4 h-4 text-text-tertiary" />
  169. <span className="text-xs text-text-quaternary">快速筛选:</span>
  170. <div className="flex items-center gap-1.5 flex-wrap">
  171. {quickFilters.map(action => (
  172. <button
  173. key={action}
  174. type="button"
  175. onClick={() => handleFilterByAction(action)}
  176. className={`px-2 py-1 rounded text-xs transition-colors ${
  177. actionFilter === action
  178. ? 'bg-components-button-primary-bg text-components-button-primary-text'
  179. : 'bg-background-section-burn text-text-tertiary hover:bg-background-default-hover'
  180. }`}
  181. >
  182. {getOperationTypeLabel(action)}
  183. </button>
  184. ))}
  185. {actionFilter && (
  186. <button
  187. type="button"
  188. onClick={() => handleFilterByAction('')}
  189. className="px-2 py-1 text-xs text-text-accent hover:underline"
  190. >
  191. 清除
  192. </button>
  193. )}
  194. </div>
  195. </div>
  196. {/* Refresh Button */}
  197. <div className="flex justify-end">
  198. <Button
  199. size="small"
  200. onClick={() => refetch()}
  201. className="gap-1"
  202. >
  203. <RiRefreshLine className="w-4 h-4" />
  204. 刷新
  205. </Button>
  206. </div>
  207. </div>
  208. {/* Logs List */}
  209. <div className="border border-divider-subtle rounded-lg overflow-hidden">
  210. {isLoading
  211. ? (
  212. <div className="flex items-center justify-center py-12">
  213. <Loading type="area" />
  214. </div>
  215. )
  216. : !hasLogs
  217. ? (
  218. <div className="text-center py-12 text-text-quaternary">
  219. <RiFileHistoryLine className="w-12 h-12 mx-auto mb-3 opacity-50" />
  220. <div className="text-sm">暂无操作历史</div>
  221. </div>
  222. )
  223. : (
  224. <div className="divide-y divide-divider-subtle max-h-[500px] overflow-y-auto">
  225. {logs.map((log: OperationLog) => (
  226. <div
  227. key={log.id}
  228. className="p-4 hover:bg-background-default-hover transition-colors"
  229. >
  230. <div className="flex items-start justify-between gap-4">
  231. <div className="flex-1 min-w-0">
  232. {/* Operation Type */}
  233. <div className="mb-2">
  234. {getOperationTypeBadge(log.action)}
  235. </div>
  236. {/* Timestamp */}
  237. <div className="text-sm text-text-secondary mb-1">
  238. {new Date(log.created_at).toLocaleString('zh-CN')}
  239. </div>
  240. {/* IP Address */}
  241. <div className="text-xs text-text-quaternary">
  242. 来自: {log.created_ip}
  243. </div>
  244. {/* Changes Summary */}
  245. {renderChangesSummary(log)}
  246. </div>
  247. </div>
  248. </div>
  249. ))}
  250. </div>
  251. )}
  252. </div>
  253. {/* Pagination */}
  254. {data && data.total > params.per_page! && (
  255. <div className="mt-4 flex items-center justify-between text-sm">
  256. <div className="text-text-quaternary">
  257. 显示 {(params.page! - 1) * params.per_page! + 1}-{Math.min(params.page! * params.per_page!, data.total)} 条,共 {data.total} 条
  258. </div>
  259. <div className="flex items-center gap-2">
  260. <Button
  261. size="small"
  262. disabled={params.page === 1}
  263. onClick={() => handlePageChange(params.page! - 1)}
  264. >
  265. 上一页
  266. </Button>
  267. <span className="text-text-tertiary">
  268. {params.page} / {data.pages}
  269. </span>
  270. <Button
  271. size="small"
  272. disabled={params.page === data.pages}
  273. onClick={() => handlePageChange(params.page! + 1)}
  274. >
  275. 下一页
  276. </Button>
  277. </div>
  278. </div>
  279. )}
  280. </div>
  281. {/* Footer */}
  282. <div className="flex justify-end pt-4 border-t border-divider-subtle">
  283. <Button onClick={onClose}>
  284. 关闭
  285. </Button>
  286. </div>
  287. </Modal>
  288. )
  289. }
  290. export default OperationLogsModal