From 9a8f99cad9691dd6ecf359350ee72598a3ed60dc Mon Sep 17 00:00:00 2001 From: Ayumi Sarah Date: Tue, 24 Oct 2023 14:45:15 +0800 Subject: [PATCH] feat: find tickets by evaluation create time (#943) --- next/api/src/model/Ticket.ts | 9 ++- next/api/src/router/ticket-stats.ts | 14 +++- next/api/src/router/ticket.ts | 18 ++++- next/api/src/ticket/export/ExportTicket.ts | 10 +++ next/api/src/utils/yup.ts | 24 ++++++ .../Filter/FilterForm/PresetRangePicker.tsx | 78 +++++++++++++++++++ .../Admin/Tickets/Filter/FilterForm/index.tsx | 14 +++- .../Admin/Tickets/Filter/useTicketFilter.tsx | 2 + next/web/src/api/ticket.ts | 11 +++ 9 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 next/web/src/App/Admin/Tickets/Filter/FilterForm/PresetRangePicker.tsx diff --git a/next/api/src/model/Ticket.ts b/next/api/src/model/Ticket.ts index c23059f0b..b63946e32 100644 --- a/next/api/src/model/Ticket.ts +++ b/next/api/src/model/Ticket.ts @@ -14,7 +14,6 @@ import { hasManyThroughPointer, hasManyThroughPointerArray, serialize, - AuthOptions, } from '@/orm'; import { TicketUpdater, UpdateOptions } from '@/ticket/TicketUpdater'; import htmlify from '@/utils/htmlify'; @@ -62,6 +61,14 @@ export class Status { export interface Evaluation { star: number; content: string; + selections?: string[]; + /** + * 评价时间 + * + * 不用 `createdAt` 是因为 API 对于名为 `createdAt` 的字段始终返回 string 类型的值 + * 这会导致获取的值与最初设置的值类型不一致, 且 JS SDK 并未兼容这一行为 + */ + ts?: Date; } export interface LatestReply extends Omit { diff --git a/next/api/src/router/ticket-stats.ts b/next/api/src/router/ticket-stats.ts index 3e0a49b67..cfca063af 100644 --- a/next/api/src/router/ticket-stats.ts +++ b/next/api/src/router/ticket-stats.ts @@ -190,9 +190,19 @@ router.get('/realtime', parseRange('createdAt'), async (ctx) => { .where('groupId', params['groupId'], 'in') .where('status', params['status'], 'in') .where('categoryId', categoryIds, 'in') - .where('ticketCreatedAt', params.createdAtFrom, '>') - .where('ticketCreatedAt', params.createdAtTo, '<') + .where('ticketCreatedAt', params.createdAtFrom, '>=') + .where('ticketCreatedAt', params.createdAtTo, '<=') .where(new FunctionColumn(`JSONExtractInt(evaluation,'star')`), params['evaluation.star']) + .where( + new FunctionColumn(`JSONExtractString(evaluation,'ts','iso')`), + params['evaluation.ts']?.[0], + '>=' + ) + .where( + new FunctionColumn(`JSONExtractString(evaluation,'ts','iso')`), + params['evaluation.ts']?.[1], + '<=' + ) .where( new FunctionColumn( `arrayExists( v ->${privateTagCondition diff --git a/next/api/src/router/ticket.ts b/next/api/src/router/ticket.ts index aac1de1dd..72bdf5d29 100644 --- a/next/api/src/router/ticket.ts +++ b/next/api/src/router/ticket.ts @@ -62,6 +62,7 @@ export const ticketFiltersSchema = yup.object({ participantId: yup.csv(yup.string().required()), status: yup.csv(yup.number().oneOf(statuses).required()), 'evaluation.star': yup.number().oneOf([0, 1]), + 'evaluation.ts': yup.dateRange(), createdAtFrom: yup.date(), createdAtTo: yup.date(), tagKey: yup.string(), @@ -191,6 +192,15 @@ router.get( if (params['evaluation.star'] !== undefined) { query.where('evaluation.star', '==', params['evaluation.star']); } + if (params['evaluation.ts']) { + const [from, to] = params['evaluation.ts']; + if (from) { + query.where('evaluation.ts', '>=', from); + } + if (to) { + query.where('evaluation.ts', '<=', to); + } + } if (params.createdAtFrom) { query.where('createdAt', '>=', params.createdAtFrom); } @@ -354,6 +364,11 @@ router.get( if (params['evaluation.star'] !== undefined) { addEqCondition('evaluation.star', params['evaluation.star']); } + if (params['evaluation.ts']) { + const from = params['evaluation.ts'][0]?.toISOString() ?? '*'; + const to = params['evaluation.ts'][1]?.toISOString() ?? '*'; + conditions.push(`evaluation.ts:[${from} TO ${to}]`); + } if (params.createdAtFrom || params.createdAtTo) { const from = params.createdAtFrom?.toISOString() ?? '*'; const to = params.createdAtTo?.toISOString() ?? '*'; @@ -845,6 +860,7 @@ router.patch('/:id', async (ctx) => { nickname: currentUser.name, }) ).unescape, + ts: new Date(), }); } @@ -1208,4 +1224,4 @@ router.post('/search-custom-field', customerServiceOnly, async (ctx) => { ctx.body = tickets.map((t) => new TicketListItemResponse(t)); }); -export default router; \ No newline at end of file +export default router; diff --git a/next/api/src/ticket/export/ExportTicket.ts b/next/api/src/ticket/export/ExportTicket.ts index 23aa62c57..c2e2a62e1 100644 --- a/next/api/src/ticket/export/ExportTicket.ts +++ b/next/api/src/ticket/export/ExportTicket.ts @@ -29,6 +29,7 @@ export interface FilterOptions { groupId?: string[]; status?: number[]; 'evaluation.star'?: number; + 'evaluation.ts'?: (Date | string | undefined | null)[]; createdAtFrom?: string | Date; createdAtTo?: string | Date; tagKey?: string; @@ -109,6 +110,15 @@ const createBaseTicketQuery = async (params: FilterOptions, sortItems?: SortItem if (params['evaluation.star'] !== undefined) { query.where('evaluation.star', '==', params['evaluation.star']); } + if (params['evaluation.ts']) { + const [from, to] = params['evaluation.ts']; + if (from) { + query.where('evaluation.ts', '>=', new Date(from)); + } + if (to) { + query.where('evaluation.ts', '<=', new Date(to)); + } + } if (params.createdAtFrom) { query.where('createdAt', '>=', new Date(params.createdAtFrom)); } diff --git a/next/api/src/utils/yup.ts b/next/api/src/utils/yup.ts index 3ab650671..097aec2f6 100644 --- a/next/api/src/utils/yup.ts +++ b/next/api/src/utils/yup.ts @@ -12,3 +12,27 @@ export const csv: typeof yup.array = (type) => { }); return schema as any; }; + +export const dateRange = () => { + const schema = yup.array(yup.date()).transform((value) => { + if (value[0] || value[1]) { + // filter [undefined, undefined]; + return value; + } + }); + schema.transforms.unshift((value) => { + if (typeof value === 'string') { + return value + .split('..') + .slice(0, 2) + .map((v) => { + if (!v || v === '*') { + return undefined; + } else { + return v; + } + }); + } + }); + return schema; +}; diff --git a/next/web/src/App/Admin/Tickets/Filter/FilterForm/PresetRangePicker.tsx b/next/web/src/App/Admin/Tickets/Filter/FilterForm/PresetRangePicker.tsx new file mode 100644 index 000000000..fdb7971b5 --- /dev/null +++ b/next/web/src/App/Admin/Tickets/Filter/FilterForm/PresetRangePicker.tsx @@ -0,0 +1,78 @@ +import { useMemo, useState } from 'react'; +import moment, { Moment } from 'moment'; +import { DatePicker, Select } from 'antd'; + +const { RangePicker } = DatePicker; + +const EMPTY_VALUE = ''; +const RANGE_VALUE = 'range'; + +const options = [ + { value: EMPTY_VALUE, label: '所有时间' }, + { value: 'today', label: '今天' }, + { value: 'yesterday', label: '昨天' }, + { value: 'week', label: '本周' }, + { value: 'month', label: '本月' }, + { value: 'lastMonth', label: '上月' }, + { value: RANGE_VALUE, label: '选择时间段' }, +]; + +interface PresetRangePickerProps { + value?: string; + onChange: (value?: string) => void; + disabled?: boolean; +} + +export function PresetRangePicker({ value, onChange, disabled }: PresetRangePickerProps) { + const rangeValue = useMemo(() => { + if (value?.includes('..')) { + return value.split('..').map((str) => moment(str)); + } + }, [value]); + + const [rangeMode, setRangeMode] = useState(rangeValue !== undefined); + + const handleChange = (value: string) => { + if (value === RANGE_VALUE) { + setRangeMode(true); + return; + } + setRangeMode(false); + onChange(value === EMPTY_VALUE ? undefined : value); + }; + + const handleChangeRange = (range: [Moment, Moment] | null) => { + if (!range) { + onChange(undefined); + return; + } + const [starts, ends] = range; + onChange(`${starts.toISOString()}..${ends.toISOString()}`); + }; + + const showRangePicker = rangeMode || rangeValue !== undefined; + return ( + <> + merge({ star })} /> + + merge({ 'evaluation.ts': value })} + /> + + @@ -161,7 +169,7 @@ const CustomFieldForm = ({ filters, merge, onSubmit }: FilterFormItemProps - merge({ createdAt })} /> + merge({ createdAt })} /> diff --git a/next/web/src/App/Admin/Tickets/Filter/useTicketFilter.tsx b/next/web/src/App/Admin/Tickets/Filter/useTicketFilter.tsx index 6290ddcc4..2f8a4b56e 100644 --- a/next/web/src/App/Admin/Tickets/Filter/useTicketFilter.tsx +++ b/next/web/src/App/Admin/Tickets/Filter/useTicketFilter.tsx @@ -21,6 +21,7 @@ export interface NormalFilters extends CommonFilters { rootCategoryId?: string; status?: number[]; star?: number; + 'evaluation.ts'?: string; language?: string[]; where?: Record; } @@ -78,6 +79,7 @@ const deserializeFilters = (params: Record): Filters 'privateTagKey', 'privateTagValue', 'rootCategoryId', + 'evaluation.ts', // field 'fieldId', diff --git a/next/web/src/api/ticket.ts b/next/web/src/api/ticket.ts index d5f792ab9..58f795c60 100644 --- a/next/web/src/api/ticket.ts +++ b/next/web/src/api/ticket.ts @@ -48,6 +48,7 @@ export interface FetchTicketFilters { language?: string[]; rootCategoryId?: string; star?: number; + 'evaluation.ts'?: string; createdAt?: string; status?: number | number[]; tagKey?: string; @@ -102,6 +103,16 @@ export function encodeTicketFilters(filters: FetchTicketFilters) { if (!isEmpty(filters.where)) { params.where = JSON.stringify(filters.where); } + if (filters['evaluation.ts']) { + const dateRange = decodeDateRange(filters['evaluation.ts']); + if (dateRange && (dateRange.from || dateRange.to)) { + // "2021-08-01..2021-08-31", "2021-08-01..*", etc. + params['evaluation.ts'] = [ + dateRange.from?.toISOString() ?? '*', + dateRange.to?.toISOString() ?? '*', + ].join('..'); + } + } return params; }