index.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914
  1. <template>
  2. <div ref="pageRef" :class="prefixCls" class="fast-transfer-page">
  3. <div class="filter-bar">
  4. <div class="filter-item">
  5. <span class="filter-label">选择对象:</span>
  6. <Select
  7. v-model:value="filters.target"
  8. class="filter-input"
  9. :options="targetOptions"
  10. placeholder="请选择对象"
  11. allow-clear
  12. />
  13. </div>
  14. <div class="filter-item">
  15. <span class="filter-label">时间间隔:</span>
  16. <Input v-model:value="filters.intervalValue" class="filter-micro" :status="intervalValueStatus" />
  17. <span class="filter-unit">h</span>
  18. </div>
  19. <div class="filter-item">
  20. <span class="filter-label">收入金额:</span>
  21. <Select v-model:value="filters.amountOp" class="filter-op" :options="amountOpOptions" />
  22. <Input v-model:value="filters.amountValue" class="filter-mini" :status="amountValueStatus" />
  23. </div>
  24. <div class="filter-item">
  25. <span class="filter-label">交易日期:</span>
  26. <RangePicker
  27. v-model:value="filters.dateRange"
  28. class="filter-date"
  29. :format="DATE_FORMAT"
  30. :value-format="DATE_FORMAT"
  31. :placeholder="['开始日期', '结束日期']"
  32. />
  33. </div>
  34. <div class="filter-actions">
  35. <Button type="primary" :loading="queryLoading" @click="handleSearch">查询</Button>
  36. <Button @click="handleReset">重置</Button>
  37. </div>
  38. </div>
  39. <div v-if="!hasSearched" class="query-state-panel query-state-panel--page">
  40. <EmptyState
  41. variant="query"
  42. title="请选择对象后查询"
  43. description="该页面必须先选择对象后,再点击查询展示资金快转数据"
  44. min-height="100%"
  45. description-width="min(100%, 420px)"
  46. />
  47. </div>
  48. <div v-else class="page-content">
  49. <div class="account-strip">
  50. <div v-if="bankCards.length" class="account-list">
  51. <div class="account-strip-head">
  52. <span class="strip-meta">已选 {{ selectedCardNos.length }} / {{ bankCards.length }} 张</span>
  53. </div>
  54. <label
  55. v-for="card in bankCards"
  56. :key="card.cardNo || card.bankName"
  57. class="account-item"
  58. :class="{ 'is-active': isCardSelected(card.cardNo) }"
  59. >
  60. <Checkbox
  61. class="account-checkbox"
  62. :checked="isCardSelected(card.cardNo)"
  63. @change="toggleCard(card.cardNo, $event.target.checked)"
  64. />
  65. <div class="account-content">
  66. <span class="account-number">{{ card.cardNo || '-' }}</span>
  67. <span class="account-bank">{{ card.bankName || '未知银行' }}</span>
  68. </div>
  69. </label>
  70. </div>
  71. <div v-else class="selection-empty">暂无银行卡数据</div>
  72. </div>
  73. <div class="timeline-panel">
  74. <div class="timeline-header">
  75. <div class="header-title income">收入</div>
  76. <div class="header-axis"></div>
  77. <div class="header-title expense">支出</div>
  78. </div>
  79. <div class="timeline-content">
  80. <template v-if="timelineRows.length">
  81. <div class="timeline-line"></div>
  82. <div v-for="item in timelineRows" :key="item.date" class="timeline-row">
  83. <div class="timeline-side income-side">
  84. <button
  85. v-if="item.income"
  86. type="button"
  87. class="flow-card income-card flow-card-button"
  88. @click="handleFlowCardClick(item, 'income')"
  89. >
  90. <span class="flow-count">收入{{ item.income.count }}笔</span>
  91. <span class="flow-amount">金额{{ formatAmount(item.income.amount) }}</span>
  92. </button>
  93. </div>
  94. <div class="center-axis">
  95. <div class="axis-dot"></div>
  96. <div class="axis-date">{{ item.date }}</div>
  97. </div>
  98. <div class="timeline-side expense-side">
  99. <button
  100. v-if="item.expense"
  101. type="button"
  102. class="flow-card expense-card flow-card-button"
  103. @click="handleFlowCardClick(item, 'expense')"
  104. >
  105. <span class="flow-count">支出{{ item.expense.count }}笔</span>
  106. <span class="flow-amount">金额{{ formatAmount(item.expense.amount) }}</span>
  107. </button>
  108. </div>
  109. </div>
  110. </template>
  111. <div v-else class="timeline-empty">
  112. <EmptyState :description="timelineEmptyDescription" />
  113. </div>
  114. </div>
  115. </div>
  116. </div>
  117. <TransRecordModal v-model:open="transRecordModalOpen" title="交易记录" :base-query="transRecordBaseQuery" />
  118. </div>
  119. </template>
  120. <script lang="ts" setup name="ViewsFastTransferIndex">
  121. import { computed, onActivated, onMounted, ref } from 'vue';
  122. import { Select, Input, Button, DatePicker, Checkbox } from 'ant-design-vue';
  123. import { EmptyState } from '@jeesite/core/components/EmptyState';
  124. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  125. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  126. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  127. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  128. import {
  129. COMPARE_OPERATOR_OPTIONS,
  130. DEFAULT_COMPARE_OPERATOR,
  131. normalizeCompareOperator,
  132. type CompareOperator,
  133. } from '@jeesite/core/utils/compareOperator';
  134. import { getPersons } from '@jeesite/person/api/personApi';
  135. import type { PersonBasicInfo } from '@jeesite/person/api/types/dto';
  136. import { statFirstTransFastFundFlow } from '@jeesite/trans/api/transApi';
  137. import type {
  138. TransFastFundFlowBankDTO,
  139. TransFastFundFlowDTO,
  140. TransFastFundFlowDayDTO,
  141. } from '@jeesite/trans/api/types/dto';
  142. import type { TransFastFundFlowQuery, TransRecordQuery } from '@jeesite/trans/api/types/query';
  143. import TransRecordModal from '../components/TransRecordModal.vue';
  144. interface TimelineSide {
  145. count: number;
  146. amount: number;
  147. ids: Array<string>;
  148. }
  149. interface TimelineRow {
  150. date: string;
  151. income: TimelineSide | null;
  152. expense: TimelineSide | null;
  153. }
  154. type FlowDirection = 'income' | 'expense';
  155. type SymbolEnum = Extract<CompareOperator, 'GE' | 'LE'>;
  156. type FastTransferQueryPayload = TransFastFundFlowQuery & {
  157. SymbolEnum?: SymbolEnum;
  158. };
  159. const { prefixCls } = useDesign('fast-transfer');
  160. const { createMessage } = useMessage();
  161. const { queryLoading, runWithQueryLoading } = useTableSkill();
  162. const RangePicker = DatePicker.RangePicker;
  163. const DATE_FORMAT = 'YYYY-MM-DD';
  164. type RangeValue = [string, string] | undefined;
  165. const filters = ref({
  166. target: '',
  167. intervalValue: '24',
  168. amountOp: DEFAULT_COMPARE_OPERATOR,
  169. amountValue: '10000',
  170. dateRange: undefined as RangeValue,
  171. });
  172. const pageRef = ref<HTMLElement | null>(null);
  173. const targetOptions = ref<Array<{ label: string; value: string }>>([]);
  174. const personList = ref<PersonBasicInfo[]>([]);
  175. const positiveIntegerPattern = /^[1-9]\d*$/;
  176. const amountPattern = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/;
  177. const fastResult = ref<TransFastFundFlowDTO | null>(null);
  178. const hasSearched = ref(false);
  179. const selectedCardNos = ref<string[]>([]);
  180. const transRecordModalOpen = ref(false);
  181. const transRecordBaseQuery = ref<Partial<TransRecordQuery>>({});
  182. const amountOpOptions = COMPARE_OPERATOR_OPTIONS.filter((item) => item.value === 'GE' || item.value === 'LE');
  183. const normalizedIntervalValue = computed(() => String(filters.value.intervalValue ?? '').trim());
  184. const normalizedAmountValue = computed(() => String(filters.value.amountValue ?? '').trim());
  185. const intervalValueStatus = computed<'error' | undefined>(() => {
  186. if (!normalizedIntervalValue.value) return undefined;
  187. return positiveIntegerPattern.test(normalizedIntervalValue.value) ? undefined : 'error';
  188. });
  189. const amountValueStatus = computed<'error' | undefined>(() => {
  190. if (!normalizedAmountValue.value) return undefined;
  191. return normalizePositiveAmountInput(normalizedAmountValue.value) === null ? 'error' : undefined;
  192. });
  193. const activePayload = computed<TransFastFundFlowDTO>(() => unwrapFastResult(fastResult.value));
  194. const bankCards = computed<TransFastFundFlowBankDTO[]>(() => {
  195. return Array.isArray(activePayload.value.banks) ? activePayload.value.banks.filter((item) => !!item?.cardNo) : [];
  196. });
  197. const selectedPerson = computed<PersonBasicInfo | null>(() => {
  198. if (!filters.value.target) return null;
  199. return personList.value.find((item) => item.personName === filters.value.target) || null;
  200. });
  201. const selectedPersonCardNos = computed<string[]>(() => {
  202. return (selectedPerson.value?.cardList || []).map((item) => String(item?.personNo || '').trim()).filter(Boolean);
  203. });
  204. const timelineRows = computed<TimelineRow[]>(() => {
  205. const list: TransFastFundFlowDayDTO[] = Array.isArray(activePayload.value.list) ? activePayload.value.list : [];
  206. if (!list.length) return [];
  207. if (bankCards.value.length && !selectedCardNos.value.length) return [];
  208. const grouped = new Map<string, TimelineRow>();
  209. list.forEach((item) => {
  210. if (item.cardNo && !selectedCardNos.value.includes(item.cardNo)) {
  211. return;
  212. }
  213. const date = item.transDate || '-';
  214. const nextRow = grouped.get(date) || {
  215. date,
  216. income: null,
  217. expense: null,
  218. };
  219. const targetKey = isIncome(item.debitCreditFlag) ? 'income' : 'expense';
  220. const target = nextRow[targetKey] || { count: 0, amount: 0, ids: [] as string[] };
  221. target.count += toNumber(item.transCount);
  222. target.amount += toNumber(item.totalTransAmount);
  223. target.ids = Array.from(new Set([...target.ids, ...normalizeRecordIds(item.idList)]));
  224. nextRow[targetKey] = target;
  225. grouped.set(date, nextRow);
  226. });
  227. return Array.from(grouped.values()).sort((a, b) => a.date.localeCompare(b.date));
  228. });
  229. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  230. refresh: () => fetchFastFundFlow(),
  231. hasData: () => hasSearched.value && bankCards.value.length > 0 && timelineRows.value.length > 0,
  232. });
  233. const timelineEmptyDescription = computed(() => {
  234. if (!filters.value.target) {
  235. return '请选择对象后再查询资金快转数据';
  236. }
  237. if (bankCards.value.length && !selectedCardNos.value.length) {
  238. return '请至少勾选一张银行卡查看对应快转时间轴';
  239. }
  240. return '当前筛选条件下暂无快转数据';
  241. });
  242. const unwrapFastResult = (resp: any): TransFastFundFlowDTO => {
  243. if (resp?.data && typeof resp.data === 'object') return resp.data as TransFastFundFlowDTO;
  244. return (resp || {}) as TransFastFundFlowDTO;
  245. };
  246. const isIncome = (flag?: string) => {
  247. const normalized = String(flag || '').trim();
  248. return normalized.includes('收') || normalized.includes('入') || normalized.includes('贷');
  249. };
  250. const toRawText = (value?: number | string | null) => {
  251. if (value === 0) return '0';
  252. if (value === null || value === undefined) return '';
  253. return String(value).trim();
  254. };
  255. const toNumber = (value?: number | string | null) => {
  256. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  257. const normalized = toRawText(value).replace(/,/g, '');
  258. if (!normalized) return 0;
  259. const parsed = Number(normalized);
  260. return Number.isFinite(parsed) ? parsed : 0;
  261. };
  262. function normalizeRecordIds(value?: Array<number | string | null>) {
  263. if (!Array.isArray(value)) return [] as string[];
  264. return value.map((item) => toRawText(item)).filter((item): item is string => !!item);
  265. }
  266. const formatAmount = (value?: number | string | null) => {
  267. const amount = toNumber(value);
  268. if (!Number.isFinite(amount)) return toRawText(value) || '-';
  269. return new Intl.NumberFormat('zh-CN', {
  270. minimumFractionDigits: 2,
  271. maximumFractionDigits: 2,
  272. }).format(amount);
  273. };
  274. const isCardSelected = (cardNo?: string) => {
  275. if (!cardNo) return false;
  276. return selectedCardNos.value.includes(cardNo);
  277. };
  278. const toggleCard = (cardNo?: string, checked = false) => {
  279. if (!cardNo) return;
  280. const nextSet = new Set(selectedCardNos.value);
  281. if (checked) {
  282. nextSet.add(cardNo);
  283. } else {
  284. nextSet.delete(cardNo);
  285. }
  286. selectedCardNos.value = Array.from(nextSet);
  287. };
  288. const normalizePositiveIntegerInput = (value?: string) => {
  289. const next = String(value ?? '').trim();
  290. if (!next) return undefined;
  291. if (!positiveIntegerPattern.test(next)) return null;
  292. const parsed = Number(next);
  293. return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
  294. };
  295. const normalizePositiveAmountInput = (value?: string) => {
  296. const next = String(value ?? '').trim();
  297. const amountForCheck = next.replace(/,/g, '');
  298. if (!amountForCheck) return undefined;
  299. if (!amountPattern.test(amountForCheck) || /^0(?:\.0{1,2})?$/.test(amountForCheck)) return null;
  300. return next;
  301. };
  302. const toFastTransferSymbolEnum = (operator?: CompareOperator): SymbolEnum =>
  303. normalizeCompareOperator(operator, DEFAULT_COMPARE_OPERATOR) === 'LE' ? 'LE' : 'GE';
  304. const buildQuery = (): FastTransferQueryPayload | null => {
  305. if (!filters.value.target) {
  306. createMessage.warning('请选择对象后再查询');
  307. return null;
  308. }
  309. const hour = normalizePositiveIntegerInput(filters.value.intervalValue);
  310. const transAmount = normalizePositiveAmountInput(filters.value.amountValue);
  311. const amountOp = normalizeCompareOperator(filters.value.amountOp, DEFAULT_COMPARE_OPERATOR);
  312. if (hour === null || hour === undefined) {
  313. createMessage.warning('时间间隔请输入大于 0 的整数');
  314. return null;
  315. }
  316. if (transAmount === null) {
  317. createMessage.warning('收入金额请输入大于 0 的数字,最多两位小数');
  318. return null;
  319. }
  320. if (transAmount !== undefined && amountOp !== 'GE' && amountOp !== 'LE') {
  321. createMessage.warning('收入金额条件仅支持 >= 或 <=');
  322. return null;
  323. }
  324. const amountSymbol = transAmount !== undefined ? toFastTransferSymbolEnum(amountOp) : undefined;
  325. return {
  326. page: 1,
  327. limit: 1000,
  328. startDate: filters.value.dateRange?.[0] || undefined,
  329. endDate: filters.value.dateRange?.[1] || undefined,
  330. personName: filters.value.target || undefined,
  331. personNames: filters.value.target ? [filters.value.target] : undefined,
  332. personCardNo: selectedPersonCardNos.value.length === 1 ? selectedPersonCardNos.value[0] : undefined,
  333. hour,
  334. transAmount,
  335. transAmountSymbol: amountSymbol,
  336. money: transAmount,
  337. SymbolEnum: amountSymbol,
  338. };
  339. };
  340. const buildTransRecordBaseQuery = (item: TimelineRow, direction: FlowDirection): Partial<TransRecordQuery> => {
  341. const ids = item[direction]?.ids || [];
  342. if (!ids.length) return {};
  343. return {
  344. id: ids.length === 1 ? String(ids[0]) : undefined,
  345. ids,
  346. };
  347. };
  348. const syncSelectedCards = (banks: TransFastFundFlowBankDTO[]) => {
  349. const nextCardNos = banks.map((item) => item.cardNo).filter((item): item is string => !!item);
  350. if (!nextCardNos.length) {
  351. selectedCardNos.value = [];
  352. return;
  353. }
  354. const nextSet = new Set(nextCardNos);
  355. const retained = selectedCardNos.value.filter((item) => nextSet.has(item));
  356. selectedCardNos.value = retained.length ? retained : nextCardNos;
  357. };
  358. const fetchFastFundFlow = async () => {
  359. const query = buildQuery();
  360. if (!query) return;
  361. await runWithQueryLoading(async () => {
  362. const resp = await statFirstTransFastFundFlow(query);
  363. const payload = unwrapFastResult(resp);
  364. fastResult.value = payload;
  365. hasSearched.value = true;
  366. syncSelectedCards(Array.isArray(payload.banks) ? payload.banks : []);
  367. syncAnalysisDataRefreshState(Array.isArray(payload.banks) && payload.banks.length > 0);
  368. });
  369. };
  370. const handleSearch = async () => {
  371. await fetchFastFundFlow();
  372. };
  373. const handleReset = async () => {
  374. filters.value = {
  375. target: '',
  376. intervalValue: '24',
  377. amountOp: DEFAULT_COMPARE_OPERATOR,
  378. amountValue: '10000',
  379. dateRange: undefined,
  380. };
  381. fastResult.value = null;
  382. hasSearched.value = false;
  383. selectedCardNos.value = [];
  384. };
  385. const handleFlowCardClick = (item: TimelineRow, direction: FlowDirection) => {
  386. const baseQuery = buildTransRecordBaseQuery(item, direction);
  387. if (!baseQuery.ids?.length && !baseQuery.id) {
  388. createMessage.warning('当前节点暂无可下探的交易记录');
  389. return;
  390. }
  391. transRecordBaseQuery.value = baseQuery;
  392. transRecordModalOpen.value = true;
  393. };
  394. const loadTargetOptions = async () => {
  395. try {
  396. personList.value = await getPersons();
  397. targetOptions.value = personList.value
  398. .filter((item) => item.personName)
  399. .map((item) => ({
  400. label: item.personName || '-',
  401. value: item.personName || '',
  402. }));
  403. } catch {
  404. personList.value = [];
  405. targetOptions.value = [];
  406. }
  407. };
  408. const resetPageScroll = () => {
  409. const pageEl = pageRef.value;
  410. pageEl?.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
  411. const layoutContentEl = pageEl?.closest('.jeesite-layout-content') as HTMLElement | null;
  412. layoutContentEl?.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
  413. };
  414. onMounted(() => {
  415. loadTargetOptions();
  416. resetPageScroll();
  417. });
  418. onActivated(() => {
  419. resetPageScroll();
  420. });
  421. </script>
  422. <style lang="less">
  423. @import '../styles/trans-analysis-table-ui.less';
  424. @prefix-cls: ~'jeesite-fast-transfer';
  425. .@{prefix-cls}.fast-transfer-page {
  426. .analysis-page-frame();
  427. .filter-input {
  428. width: 180px;
  429. }
  430. .filter-mini {
  431. width: 96px;
  432. }
  433. .filter-micro {
  434. width: 86px;
  435. }
  436. .filter-unit {
  437. color: #6b7280;
  438. font-size: 12px;
  439. }
  440. .filter-op {
  441. width: 64px;
  442. min-width: 64px;
  443. }
  444. .filter-date {
  445. width: 360px;
  446. }
  447. .filter-actions :deep(.ant-btn) {
  448. min-width: 68px;
  449. }
  450. .page-content {
  451. display: flex;
  452. flex-direction: column;
  453. gap: 2px;
  454. }
  455. .query-state-panel {
  456. .analysis-card-shell();
  457. flex: 1 1 auto;
  458. min-height: 420px;
  459. display: flex;
  460. align-items: center;
  461. justify-content: center;
  462. }
  463. .query-state-panel--page {
  464. margin-top: 2px;
  465. }
  466. .account-strip {
  467. .analysis-card-shell();
  468. flex: 0 0 auto;
  469. }
  470. .account-list {
  471. display: flex;
  472. flex-wrap: wrap;
  473. gap: 10px 28px;
  474. align-items: flex-start;
  475. }
  476. .account-strip-head {
  477. width: 100%;
  478. display: flex;
  479. align-items: center;
  480. justify-content: space-between;
  481. gap: 8px;
  482. margin-bottom: 6px;
  483. }
  484. .strip-meta {
  485. color: #6b7280;
  486. font-size: 12px;
  487. line-height: 18px;
  488. }
  489. .account-item {
  490. display: inline-flex;
  491. align-items: flex-start;
  492. gap: 8px;
  493. min-width: 150px;
  494. padding: 6px 10px 7px;
  495. border: 1px solid transparent;
  496. border-radius: 8px;
  497. cursor: pointer;
  498. user-select: none;
  499. transition:
  500. background-color 0.2s ease,
  501. border-color 0.2s ease,
  502. box-shadow 0.2s ease;
  503. }
  504. .account-item:hover {
  505. background: rgba(22, 119, 255, 0.04);
  506. }
  507. .account-checkbox {
  508. margin-top: 2px;
  509. }
  510. .account-content {
  511. display: flex;
  512. flex-direction: column;
  513. gap: 4px;
  514. min-width: 0;
  515. }
  516. .selection-empty {
  517. padding: 8px 0;
  518. color: #9ca3af;
  519. font-size: 12px;
  520. line-height: 20px;
  521. }
  522. .account-number {
  523. color: #1f2937;
  524. font-size: 13px;
  525. line-height: 20px;
  526. font-family: Consolas, 'Courier New', monospace;
  527. transition: color 0.2s ease;
  528. }
  529. .account-bank {
  530. color: #374151;
  531. font-size: 13px;
  532. line-height: 20px;
  533. transition: color 0.2s ease;
  534. }
  535. .account-item.is-active {
  536. border-color: #cfe4ff;
  537. background: #edf6ff;
  538. box-shadow: inset 0 0 0 1px rgba(22, 119, 255, 0.04);
  539. .account-number,
  540. .account-bank {
  541. color: #1697ff;
  542. font-weight: 600;
  543. }
  544. :deep(.ant-checkbox-inner) {
  545. border-color: #1697ff;
  546. }
  547. }
  548. .timeline-panel {
  549. .analysis-card-shell();
  550. flex: 1 1 auto;
  551. min-height: 420px;
  552. overflow: hidden;
  553. display: flex;
  554. flex-direction: column;
  555. }
  556. .timeline-header {
  557. display: grid;
  558. grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr);
  559. align-items: center;
  560. padding: 20px 0 10px;
  561. .header-title {
  562. text-align: center;
  563. font-size: 19px;
  564. font-weight: 600;
  565. line-height: 28px;
  566. color: #111827;
  567. &.income {
  568. color: #333333;
  569. }
  570. &.expense {
  571. color: #333333;
  572. }
  573. }
  574. .header-axis {
  575. min-height: 1px;
  576. }
  577. }
  578. .timeline-content {
  579. position: relative;
  580. flex: 1 1 auto;
  581. min-height: 0;
  582. padding: 18px 0 72px;
  583. overflow: auto;
  584. scroll-padding-bottom: 72px;
  585. }
  586. .timeline-empty {
  587. height: 100%;
  588. min-height: 260px;
  589. display: flex;
  590. align-items: center;
  591. justify-content: center;
  592. }
  593. .timeline-line {
  594. position: absolute;
  595. left: 50%;
  596. top: 22px;
  597. bottom: 84px;
  598. width: 1px;
  599. background: #bdbdbd;
  600. transform: translateX(-50%);
  601. }
  602. .timeline-row {
  603. position: relative;
  604. display: grid;
  605. grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr);
  606. align-items: center;
  607. min-height: 112px;
  608. margin-bottom: 18px;
  609. }
  610. .timeline-side {
  611. display: flex;
  612. align-items: center;
  613. justify-content: center;
  614. min-height: 100%;
  615. }
  616. .income-side {
  617. padding-right: 30px;
  618. }
  619. .expense-side {
  620. padding-left: 30px;
  621. }
  622. .center-axis {
  623. position: relative;
  624. z-index: 1;
  625. display: flex;
  626. flex-direction: column;
  627. align-items: center;
  628. justify-content: center;
  629. gap: 10px;
  630. }
  631. .axis-dot {
  632. width: 16px;
  633. height: 16px;
  634. border-radius: 50%;
  635. background: #7b7b7b;
  636. border: 2px solid #efefef;
  637. box-shadow:
  638. 0 0 0 2px #d9d9d9,
  639. 0 2px 8px rgba(15, 23, 42, 0.14);
  640. }
  641. .axis-date {
  642. color: #4b5563;
  643. font-size: 12px;
  644. line-height: 18px;
  645. font-weight: 500;
  646. white-space: nowrap;
  647. text-align: center;
  648. }
  649. .flow-card {
  650. display: inline-flex;
  651. align-items: center;
  652. justify-content: space-between;
  653. gap: 28px;
  654. min-width: 210px;
  655. max-width: 260px;
  656. padding: 11px 18px;
  657. border-radius: 8px;
  658. font-size: 12px;
  659. line-height: 18px;
  660. box-sizing: border-box;
  661. box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
  662. .flow-count,
  663. .flow-amount {
  664. font-weight: 600;
  665. }
  666. }
  667. .flow-card-button {
  668. border: none;
  669. cursor: pointer;
  670. transition:
  671. transform 0.2s ease,
  672. box-shadow 0.2s ease,
  673. filter 0.2s ease;
  674. &:hover {
  675. transform: translateY(-1px);
  676. filter: brightness(1.01);
  677. box-shadow: 0 12px 22px rgba(15, 23, 42, 0.08);
  678. }
  679. }
  680. .income-card {
  681. background: #f79a13;
  682. .flow-count,
  683. .flow-amount {
  684. color: #ffffff;
  685. }
  686. }
  687. .expense-card {
  688. background: #c7f169;
  689. .flow-count,
  690. .flow-amount {
  691. color: #365200;
  692. }
  693. }
  694. :deep(.ant-select-selector),
  695. :deep(.ant-input),
  696. :deep(.ant-picker),
  697. :deep(.ant-btn) {
  698. border-radius: 8px;
  699. }
  700. @media screen and (max-width: 992px) {
  701. .filter-actions {
  702. width: 100%;
  703. margin-left: 0;
  704. }
  705. .filter-input,
  706. .filter-date {
  707. width: 100%;
  708. }
  709. .account-list {
  710. gap: 10px 18px;
  711. }
  712. .account-strip-head {
  713. margin-bottom: 8px;
  714. }
  715. .timeline-header,
  716. .timeline-row {
  717. grid-template-columns: minmax(0, 1fr) 96px minmax(0, 1fr);
  718. }
  719. .income-side {
  720. padding-right: 18px;
  721. }
  722. .expense-side {
  723. padding-left: 18px;
  724. }
  725. .timeline-header {
  726. padding-top: 18px;
  727. }
  728. .flow-card {
  729. min-width: 144px;
  730. gap: 14px;
  731. padding: 10px 12px;
  732. }
  733. }
  734. @media screen and (max-width: 640px) {
  735. .account-strip {
  736. padding-left: 10px;
  737. padding-right: 10px;
  738. }
  739. .account-strip-head {
  740. align-items: flex-start;
  741. flex-direction: column;
  742. gap: 4px;
  743. }
  744. .timeline-header {
  745. display: none;
  746. }
  747. .timeline-row {
  748. grid-template-columns: 1fr;
  749. gap: 10px;
  750. padding-left: 40px;
  751. margin-bottom: 18px;
  752. }
  753. .timeline-line {
  754. left: 18px;
  755. top: 4px;
  756. bottom: 20px;
  757. }
  758. .center-axis {
  759. position: absolute;
  760. left: 18px;
  761. top: 12px;
  762. transform: translateX(-50%);
  763. gap: 6px;
  764. }
  765. .timeline-side {
  766. justify-content: flex-start;
  767. }
  768. .income-side,
  769. .expense-side {
  770. padding: 0;
  771. }
  772. .flow-card {
  773. width: 100%;
  774. max-width: none;
  775. }
  776. }
  777. }
  778. @import '../styles/trans-analysis-table-ui.less';
  779. </style>