case.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. <template>
  2. <div :class="prefixCls" class="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-light-400">
  3. <div class="case-header-bar flex items-center justify-between">
  4. <AppLogo :showTitle="true" />
  5. <div class="flex items-center gap-3">
  6. <span v-if="accountDisplayName" class="text-sm text-gray-600">{{ accountDisplayName }}</span>
  7. <Button type="text" size="small" @click="handleLogout">退出登录</Button>
  8. </div>
  9. </div>
  10. <div class="container relative mx-auto flex h-full min-h-0 flex-1 flex-col py-2">
  11. <div class="flex h-full min-h-0 flex-col">
  12. <div class="flex items-start justify-between gap-4">
  13. <div>
  14. <h1 class="text-2xl">案件管理</h1>
  15. <p class="mt-2 text-gray-600 dark:text-gray-400">管理您的所有案件</p>
  16. </div>
  17. </div>
  18. <div class="mb-6">
  19. <Input
  20. v-model:value="searchKeyword"
  21. placeholder="请输入案件名称进行搜索"
  22. size="large"
  23. allowClear
  24. autocomplete="off"
  25. @change="handleSearch"
  26. >
  27. <template #prefix>
  28. <SearchOutlined />
  29. </template>
  30. </Input>
  31. </div>
  32. <div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-4">
  33. <div class="grid grid-cols-1 gap-6 pb-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
  34. <Card
  35. class="case-card add-card hover:shadow-lg transition-shadow duration-300 cursor-pointer border-dashed"
  36. @click="handleAdd"
  37. >
  38. <div class="flex flex-col items-center justify-center py-8">
  39. <PlusOutlined class="text-4xl text-gray-400 mb-4" />
  40. <span class="text-gray-500 text-lg">点击创建案件</span>
  41. </div>
  42. </Card>
  43. <Card
  44. size="small"
  45. v-for="item in filteredCaseList"
  46. :key="item.id"
  47. class="case-card hover:shadow-lg transition-shadow duration-300"
  48. >
  49. <template #title>
  50. <div class="flex items-center justify-between">
  51. <span class="font-semibold text-lg truncate flex-1">{{ item.name }}</span>
  52. <div class="flex items-center">
  53. <Button type="link" size="small" color="primary" :skill="true" @click="handleEdit(item)">
  54. <template #icon>
  55. <EditOutlined />
  56. </template>
  57. </Button>
  58. <Button type="link" size="small" danger :skill="true" @click="handleDelete(item.id!)">
  59. <template #icon>
  60. <DeleteOutlined />
  61. </template>
  62. </Button>
  63. </div>
  64. </div>
  65. </template>
  66. <div>
  67. <div class="mb-3">
  68. <span class="text-gray-500 dark:text-gray-400 text-sm">案件类型:</span>
  69. <Tag :color="getTypeColor(item.type)">{{ item.type }}</Tag>
  70. </div>
  71. <div class="mb-3">
  72. <span class="text-gray-500 dark:text-gray-400 text-sm">创建时间:</span>
  73. <span class="text-gray-700 dark:text-gray-300 text-sm">{{ item.createTime }}</span>
  74. </div>
  75. <div>
  76. <span class="text-gray-500 dark:text-gray-400 text-sm">描述:</span>
  77. <span class="case-card__remark text-gray-700 dark:text-gray-300 text-sm" :title="item.remark || '-'">
  78. {{ item.remark || '-' }}
  79. </span>
  80. </div>
  81. </div>
  82. <template #actions>
  83. <div class="flex justify-center">
  84. <Button type="primary" :skill="true" @click="handleOpen(item.id!)">
  85. <template #icon>
  86. <FolderOpenOutlined />
  87. </template>
  88. 打开案件
  89. </Button>
  90. </div>
  91. </template>
  92. </Card>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <div class="case-footer flex-shrink-0 border-t border-gray-200 bg-white px-6 py-2.5">
  98. <div class="mx-auto flex max-w-7xl items-center justify-between text-xs text-gray-400">
  99. <span>© 2026 中数矩阵(成都)科技有限公司</span>
  100. <span>联系电话:18908093240</span>
  101. </div>
  102. </div>
  103. <CaseForm @register="registerModal" @success="handleSuccess" @openCase="handleOpenCreatedCase" />
  104. <Modal
  105. centered
  106. v-model:open="deleteModalVisible"
  107. title="确认删除案件吗?"
  108. :confirmLoading="deleteLoading"
  109. @ok="confirmDelete"
  110. @cancel="cancelDelete"
  111. >
  112. <div class="py-4">
  113. <p class="m-0 text-sm text-gray-500">
  114. 删除后该案件的数据库文件与工作空间会被一并清理,且不可恢复。
  115. </p>
  116. </div>
  117. </Modal>
  118. </div>
  119. </template>
  120. <script lang="ts" setup>
  121. import { computed, onMounted, ref, toRaw } from 'vue';
  122. import { Card, Button, Tag, Input, Modal, Tooltip } from 'ant-design-vue';
  123. import {
  124. DeleteOutlined,
  125. FolderOpenOutlined,
  126. SearchOutlined,
  127. PlusOutlined,
  128. EditOutlined,
  129. } from '@ant-design/icons-vue';
  130. import { useDesign } from '@/core/hooks/web/useDesign';
  131. import { useMessage } from '@/core/hooks/web/useMessage';
  132. import { useUserStore } from '@/core/store/modules/case';
  133. import { useAccountStore } from '@/core/store/modules/account';
  134. import { useRouter } from 'vue-router';
  135. import { PageEnum } from '@/core/enums/pageEnum';
  136. import { useModal } from '@/core/components/Modal';
  137. import { AppLogo, AppSearch } from '@/core/components/Application';
  138. import { list, deleteById } from '@/plat/api/case/caseApi';
  139. import type { CaseInfo } from '@/plat/api/case/caseApi';
  140. import CaseForm from './CaseForm.vue';
  141. const { showMessage, notification, createConfirm } = useMessage();
  142. const { prefixCls } = useDesign('case-list');
  143. const userStore = useUserStore();
  144. const accountStore = useAccountStore();
  145. const router = useRouter();
  146. const accountDisplayName = computed(() => accountStore.getDisplayName);
  147. /**
  148. * 退出登录。
  149. *
  150. * <p>案件列表页没有 Layout Header,所以入口放在本页顶部。
  151. * 注意与「退出当前案件」(Header 里的 LogoutButton)语义不同:这里会注销 token。
  152. */
  153. function handleLogout() {
  154. createConfirm({
  155. iconType: 'warning',
  156. title: '提示',
  157. content: '确认退出登录?退出后需要重新输入账号密码。',
  158. onOk: async () => {
  159. userStore.resetState();
  160. await accountStore.logout();
  161. await router.replace({ path: PageEnum.BASE_AUTHORIZATION });
  162. },
  163. });
  164. }
  165. const caseList = ref<CaseInfo[]>([]);
  166. const searchKeyword = ref('');
  167. const deleteModalVisible = ref(false);
  168. const deleteLoading = ref(false);
  169. const currentDeleteId = ref<number>(0);
  170. const [registerModal, { openModal }] = useModal();
  171. async function loadCaseList() {
  172. const data = await list();
  173. caseList.value = data || [];
  174. }
  175. onMounted(() => {
  176. loadCaseList();
  177. });
  178. function getCaseSortTime(item: CaseInfo) {
  179. const rawTime = item.createTime?.trim();
  180. if (!rawTime) return 0;
  181. const timestamp = new Date(rawTime.replace(/-/g, '/')).getTime();
  182. return Number.isNaN(timestamp) ? 0 : timestamp;
  183. }
  184. const getLogoWidth = computed(() => {
  185. return {};
  186. });
  187. const filteredCaseList = computed(() => {
  188. const sortedList = [...caseList.value].sort((left, right) => {
  189. const timeDiff = getCaseSortTime(right) - getCaseSortTime(left);
  190. if (timeDiff !== 0) return timeDiff;
  191. return Number(right.id || 0) - Number(left.id || 0);
  192. });
  193. if (!searchKeyword.value) {
  194. return sortedList;
  195. }
  196. const keyword = searchKeyword.value.toLowerCase();
  197. return sortedList.filter((item) => item.name.toLowerCase().includes(keyword));
  198. });
  199. function handleSearch() {
  200. console.log('搜索关键词:', searchKeyword.value);
  201. }
  202. function getTypeColor(type: string): string {
  203. const colorMap: Record<string, string> = {
  204. 刑事案件: 'red',
  205. 民事案件: 'blue',
  206. 行政案件: 'orange',
  207. 经济案件: 'green',
  208. 知识产权: 'purple',
  209. 劳动争议: 'cyan',
  210. };
  211. return colorMap[type] || 'default';
  212. }
  213. async function handleDelete(id: number) {
  214. currentDeleteId.value = id;
  215. deleteModalVisible.value = true;
  216. }
  217. async function confirmDelete() {
  218. deleteLoading.value = true;
  219. try {
  220. // 案件已归属个人,后端不再校验案件密码
  221. await deleteById(currentDeleteId.value);
  222. const index = caseList.value.findIndex((item) => item.id === currentDeleteId.value);
  223. if (index > -1) {
  224. caseList.value.splice(index, 1);
  225. }
  226. deleteModalVisible.value = false;
  227. } catch (error) {
  228. // 错误提示由 defHttp 统一弹出
  229. } finally {
  230. deleteLoading.value = false;
  231. }
  232. }
  233. function cancelDelete() {
  234. deleteModalVisible.value = false;
  235. currentDeleteId.value = 0;
  236. }
  237. /** 打开案件:案件归属当前用户,无需再输案件密码 */
  238. async function handleOpen(id: number) {
  239. await doOpenCase(id);
  240. }
  241. async function doOpenCase(id: number) {
  242. try {
  243. const matchedCase = caseList.value.find((item) => Number(item.id) === Number(id));
  244. if (matchedCase?.name) {
  245. userStore.setPageCache('currentCaseName', matchedCase.name);
  246. }
  247. const res = await userStore.openCase(toRaw({ id }));
  248. if (!res?.name && matchedCase?.name) {
  249. userStore.setCaseInfo({
  250. ...(userStore.getCaseInfo || {}),
  251. ...res,
  252. id,
  253. name: matchedCase.name,
  254. });
  255. }
  256. notification.success({
  257. message: '打开成功',
  258. description: res?.name || matchedCase?.name || '',
  259. duration: 2,
  260. });
  261. return true;
  262. } catch (error: any) {
  263. const err: string = error?.toString?.() ?? '';
  264. if (error?.code === 'ECONNABORTED' && err.indexOf('timeout of') !== -1) {
  265. showMessage('请求超时');
  266. } else if (err.indexOf('Network Error') !== -1) {
  267. showMessage('网络异常');
  268. } else if (error?.code === 'ERR_BAD_RESPONSE') {
  269. showMessage('请求失败');
  270. }
  271. console.log(error);
  272. return false;
  273. }
  274. }
  275. function handleAdd() {
  276. openModal(true, { isEdit: false, caseList: toRaw(caseList.value) });
  277. }
  278. function handleEdit(item: CaseInfo) {
  279. openModal(true, { isEdit: true, ...item, caseList: toRaw(caseList.value) });
  280. }
  281. function handleSuccess() {
  282. loadCaseList();
  283. }
  284. async function handleOpenCreatedCase(payload: { id: number; onFinished?: (success: boolean) => void }) {
  285. const success = await doOpenCase(payload.id);
  286. payload.onFinished?.(success);
  287. }
  288. </script>
  289. <style lang="less">
  290. @prefix-cls: ~'jeesite-case-list';
  291. .@{prefix-cls} {
  292. min-height: 100%;
  293. display: flex;
  294. flex-direction: column;
  295. .case-header-bar {
  296. height: 48px;
  297. padding: 0 12px 0 0;
  298. background: #fff;
  299. -webkit-app-region: drag;
  300. .jeesite-app-logo {
  301. -webkit-app-region: no-drag;
  302. &__title {
  303. display: inline-flex;
  304. align-items: baseline;
  305. gap: 6px;
  306. font-size: 18px;
  307. font-weight: bold;
  308. color: #555;
  309. font-family: Arial, 'Microsoft YaHei', sans-serif;
  310. cursor: default;
  311. user-select: none;
  312. transition: all 0.5s;
  313. }
  314. }
  315. .jeesite-app-logo img {
  316. width: 32px;
  317. height: 32px;
  318. margin-right: 2px;
  319. }
  320. .jeesite-header-action-button {
  321. display: inline-flex;
  322. align-items: center;
  323. justify-content: center;
  324. width: 36px;
  325. height: 36px;
  326. border-radius: 10px;
  327. color: #333;
  328. font-size: 18px;
  329. cursor: pointer;
  330. transition: background-color 0.2s ease;
  331. -webkit-app-region: no-drag;
  332. &:hover {
  333. background-color: #f6f6f6;
  334. }
  335. }
  336. }
  337. .case-card {
  338. border-radius: 12px;
  339. transition: all 0.3s ease;
  340. &:hover {
  341. transform: translateY(-4px);
  342. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  343. }
  344. &.add-card {
  345. border: 2px dashed #d9d9d9;
  346. background: #fafafa;
  347. min-height: 229px;
  348. display: flex;
  349. align-items: center;
  350. justify-content: center;
  351. .ant-card-body {
  352. padding: 0;
  353. width: 100%;
  354. display: flex;
  355. align-items: center;
  356. justify-content: center;
  357. }
  358. &:hover {
  359. border-color: #3f61ee;
  360. background: #e6f7ff;
  361. .anticon-plus {
  362. color: #3f61ee;
  363. }
  364. }
  365. }
  366. .ant-card-head {
  367. border-bottom: 1px solid #f0f0f0;
  368. padding: 16px;
  369. .ant-card-head-title {
  370. padding: 0;
  371. }
  372. }
  373. .ant-card-body {
  374. padding: 10px;
  375. }
  376. .case-card__remark {
  377. display: inline-block;
  378. max-width: calc(100% - 52px);
  379. overflow: hidden;
  380. text-overflow: ellipsis;
  381. white-space: nowrap;
  382. vertical-align: bottom;
  383. }
  384. .ant-card-actions {
  385. border-top: 1px solid #f0f0f0;
  386. background: #fafafa;
  387. }
  388. }
  389. }
  390. html[data-theme='dark'] {
  391. .@{prefix-cls} {
  392. background-color: #141414;
  393. .case-card {
  394. background: #1f1f1f;
  395. border-color: #303030;
  396. &.add-card {
  397. border-color: #434343;
  398. background: #1f1f1f;
  399. .ant-card-body {
  400. padding: 0;
  401. width: 100%;
  402. display: flex;
  403. align-items: center;
  404. justify-content: center;
  405. }
  406. &:hover {
  407. border-color: #177ddc;
  408. background: #111b26;
  409. }
  410. }
  411. .ant-card-head {
  412. border-bottom-color: #303030;
  413. }
  414. .ant-card-body {
  415. color: rgba(255, 255, 255, 0.85);
  416. }
  417. .ant-card-actions {
  418. border-top-color: #303030;
  419. background: #141414;
  420. }
  421. &:hover {
  422. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  423. }
  424. }
  425. }
  426. }
  427. </style>