diff --git a/next/api/src/response/ticket.ts b/next/api/src/response/ticket.ts index aac58030a..45779aa20 100644 --- a/next/api/src/response/ticket.ts +++ b/next/api/src/response/ticket.ts @@ -61,6 +61,7 @@ export class TicketResponse extends BaseTicketResponse { return { ...super.toJSON(options), metaData: this.ticket.metaData, + content: this.ticket.content, contentSafeHTML: sanitize(this.ticket.contentHTML), }; } diff --git a/next/api/src/router/ticket.ts b/next/api/src/router/ticket.ts index f2d05e8cb..324da2a36 100644 --- a/next/api/src/router/ticket.ts +++ b/next/api/src/router/ticket.ts @@ -640,6 +640,8 @@ const updateTicketSchema = yup.object({ privateTags: yup.array(ticketTagSchema.required()), evaluation: ticketEvaluationSchema.default(undefined), language: yup.string().oneOf(allowedTicketLanguages).nullable(), + title: yup.string(), + content: yup.string(), }); router.patch('/:id', async (ctx) => { @@ -748,6 +750,18 @@ router.patch('/:id', async (ctx) => { updater.setLanguage(data.language); } + if (data.title || data.content) { + if (!isCustomerService || currentUser.id !== ticket.authorId ) { + ctx.throw(403, 'Update title or content is not allowed'); + } + if (data.title) { + updater.setTitle(data.title); + } + if (data.content) { + updater.setContent(data.content); + } + } + await updater.update(currentUser); ctx.body = {}; diff --git a/next/api/src/ticket/TicketUpdater.ts b/next/api/src/ticket/TicketUpdater.ts index 72cd1f2ad..909238e00 100644 --- a/next/api/src/ticket/TicketUpdater.ts +++ b/next/api/src/ticket/TicketUpdater.ts @@ -12,6 +12,7 @@ import { systemUser, TinyUserInfo, User } from '@/model/User'; import { TinyReplyInfo } from '@/model/Reply'; import { TicketLog } from '@/model/TicketLog'; import { searchTicketService } from '@/service/search-ticket'; +import htmlify from '@/utils/htmlify'; export interface UpdateOptions { useMasterKey?: boolean; @@ -185,6 +186,15 @@ export class TicketUpdater { } } + setTitle(title: string) { + this.data.title = title; + } + + setContent(content: string) { + this.data.content = content; + this.data.contentHTML = htmlify(content); + } + operate(action: OperateAction): this { if (this.data.status) { throw new Error('Cannot operate ticket after change status'); diff --git a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx index cad6e2696..5ea72cfc9 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx @@ -50,7 +50,7 @@ import { SubscribeButton } from './components/SubscribeButton'; import { PrivateSelect } from './components/PrivateSelect'; import { CategoryCascader } from './components/CategoryCascader'; import { LeanCloudApp } from './components/LeanCloudApp'; -import { ReplyCard } from './components/ReplyCard'; +import { TicketCard } from './components/TicketCard'; import { useMixedTicket } from './mixed-ticket'; import { TicketField_v1, useTicketFields_v1 } from './api1'; import { CustomFields } from './components/CustomFields'; @@ -63,6 +63,8 @@ export function TicketDetail() { const { id } = useParams() as { id: string }; const navigate = useNavigate(); + const user = useCurrentUser(); + const { ticket, update, updating, refetch } = useMixedTicket(id); useTitle(ticket ? ticket.title : 'Loading', { restoreOnUnmount: true, @@ -134,12 +136,10 @@ export function TicketDetail() { : 'unknown'} - createTime={ticket.createdAt} - content={ticket.contentSafeHTML} - files={ticket.files} + } replies={replies} diff --git a/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx b/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx index 1e55303c9..b8149d478 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx @@ -5,7 +5,7 @@ import cx from 'classnames'; import { ReplySchema, useDeleteReply, useUpdateReply } from '@/api/reply'; import { OpsLog as OpsLogSchema } from '@/api/ticket'; import { UserLabel } from '@/App/Admin/components'; -import { ReplyCard } from '../components/ReplyCard'; +import { ReplyCard, ReplyCardProps } from '../components/ReplyCard'; import { OpsLog } from '../components/OpsLog'; import { EditReplyModal, EditReplyModalRef } from '../components/EditReplyModal'; import { ReplyRevisionsModal, ReplyRevisionsModalRef } from '../components/ReplyRevisionsModal'; @@ -60,6 +60,21 @@ export function Timeline({ header, replies, opsLogs, onRefetchReplies }: Timelin }, }); + const createMenuItems = (reply: ReplySchema) => { + if (!reply.isCustomerService) return; + const menuItems: ReplyCardProps['menuItems'] = []; + if (reply.deletedAt) { + menuItems.push({ label: '修改记录', key: 'revisions' }); + } else { + menuItems.push({ label: '编辑', key: 'edit' }); + if (reply.edited) { + menuItems.push({ label: '修改记录', key: 'revisions' }); + } + menuItems.push({ type: 'divider' }, { label: '删除', key: 'delete', danger: true }); + } + return menuItems; + }; + const handleClickMenu = (reply: ReplySchema, key: string) => { switch (key) { case 'edit': @@ -101,6 +116,7 @@ export function Timeline({ header, replies, opsLogs, onRefetchReplies }: Timelin isInternal={timeline.data.internal} edited={timeline.data.edited} deleted={!!timeline.data.deletedAt} + menuItems={createMenuItems(timeline.data)} onClickMenu={(key) => handleClickMenu(timeline.data, key)} /> ); diff --git a/next/web/src/App/Admin/Tickets/Ticket/components/ReplyCard.tsx b/next/web/src/App/Admin/Tickets/Ticket/components/ReplyCard.tsx index ea631fa47..a3a201a5b 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/components/ReplyCard.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/components/ReplyCard.tsx @@ -127,7 +127,7 @@ export const BasicReplyCard = forwardRef( } ); -interface ReplyCardProps { +export interface ReplyCardProps { id: string; author: ReactNode; createTime: string; @@ -137,6 +137,7 @@ interface ReplyCardProps { isInternal?: boolean; edited?: boolean; deleted?: boolean; + menuItems?: ItemType[]; onClickMenu?: (key: string) => void; } @@ -150,29 +151,11 @@ export function ReplyCard({ isInternal, edited, deleted, + menuItems, onClickMenu, }: ReplyCardProps) { const [translation, toggleTranslation] = useToggle(false); - const menuItems = useMemo(() => { - const items: ItemType[] = [ - { label: '复制链接', key: 'copyLink' }, - { label: '翻译', key: 'translate' }, - ]; - if (isAgent) { - if (!deleted) { - items.push({ type: 'divider' }, { label: '编辑', key: 'edit' }); - if (edited) { - items.push({ label: '修改记录', key: 'revisions' }); - } - items.push({ type: 'divider' }, { label: '删除', key: 'delete', danger: true }); - } else if (edited) { - items.push({ label: '修改记录', key: 'revisions' }); - } - } - return items; - }, [isAgent, edited, deleted]); - const handleClickMenu = ({ key }: { key: string }) => { if (key === 'translate') { toggleTranslation(); @@ -222,7 +205,18 @@ export function ReplyCard({ } tags={tags} - menu={collapsed ? undefined : { items: menuItems, onClick: handleClickMenu }} + menu={ + collapsed + ? undefined + : { + items: [ + { label: '复制链接', key: 'copyLink' }, + { label: '翻译', key: 'translate' }, + ...(menuItems?.length ? [{ type: 'divider' } as const, ...menuItems] : []), + ], + onClick: handleClickMenu, + } + } files={files} active={active} deleted={deleted} diff --git a/next/web/src/App/Admin/Tickets/Ticket/components/TicketCard.tsx b/next/web/src/App/Admin/Tickets/Ticket/components/TicketCard.tsx new file mode 100644 index 000000000..3cac71ee9 --- /dev/null +++ b/next/web/src/App/Admin/Tickets/Ticket/components/TicketCard.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useToggle } from 'react-use'; +import { Input, Modal } from 'antd'; + +import { UserLabel, UserLabelProps } from '@/App/Admin/components/UserLabel'; +import { ReplyCard, ReplyCardProps } from './ReplyCard'; +import { MarkdownEditor } from './ReplyEditor'; + +interface UpdateData { + title: string; + content: string; +} + +export interface TicketCardProps { + ticket: { + id: string; + author?: UserLabelProps['user']; + authorId: string; + createdAt: string; + title: string; + content: string; + contentSafeHTML: string; + files?: ReplyCardProps['files']; + }; + updateable?: boolean; + onUpdate?: (data: UpdateData) => void | Promise; +} + +export function TicketCard({ ticket, updateable, onUpdate }: TicketCardProps) { + const [editModalOpen, toggleEditModal] = useToggle(false); + const [tempTitle, setTempTitle] = useState(''); + const [tempContent, setTempContent] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + + const handleEdit = () => { + setTempTitle(ticket.title); + setTempContent(ticket.content); + toggleEditModal(); + }; + + const handleUpdate = async () => { + if (isUpdating || !onUpdate) { + return; + } + setIsUpdating(true); + try { + await onUpdate({ title: tempTitle, content: tempContent }); + toggleEditModal(false); + } finally { + setIsUpdating(false); + } + }; + + return ( + <> + : 'unknown'} + createTime={ticket.createdAt} + content={ticket.contentSafeHTML} + files={ticket.files} + menuItems={ + updateable + ? [ + { + key: 'edit', + label: '编辑', + onClick: handleEdit, + }, + ] + : undefined + } + /> + + + setTempTitle(e.target.value)} + style={{ marginBottom: 20 }} + /> + + + + ); +} diff --git a/next/web/src/App/Admin/Tickets/Ticket/mixed-ticket.tsx b/next/web/src/App/Admin/Tickets/Ticket/mixed-ticket.tsx index 7a05fc75e..4af4607b3 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/mixed-ticket.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/mixed-ticket.tsx @@ -12,10 +12,12 @@ interface MixedTicket { nid: TicketDetailSchema['nid']; categoryId: TicketDetailSchema['categoryId']; author: TicketDetailSchema['author']; + authorId: TicketDetailSchema['authorId']; groupId: TicketDetailSchema['groupId']; assigneeId: TicketDetailSchema['assigneeId']; status: TicketDetailSchema['status']; title: TicketDetailSchema['title']; + content: TicketDetailSchema['content']; contentSafeHTML: TicketDetailSchema['contentSafeHTML']; files: TicketDetailSchema['files']; language: TicketDetailSchema['language']; @@ -32,6 +34,8 @@ interface MixedTicket { } interface MixedUpdateData { + title?: UpdateTicketData['title']; + content?: UpdateTicketData['content']; categoryId?: UpdateTicketData['categoryId']; groupId?: UpdateTicketData['groupId']; assigneeId?: UpdateTicketData['assigneeId']; @@ -46,7 +50,7 @@ interface MixedUpdateData { interface UseMixedTicketResult { ticket?: MixedTicket; - update: (data: MixedUpdateData) => void; + update: (data: MixedUpdateData) => Promise; updating: boolean; refetch: () => void; } @@ -59,7 +63,7 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult { enabled: !!ticket, }); - const { mutate: updateTicket, isLoading: ticketUpdating } = useUpdateTicket({ + const { mutateAsync: updateTicket, isLoading: ticketUpdating } = useUpdateTicket({ onSuccess: (_, [_id, data]) => { refetch(); if (data.tags || data.privateTags) { @@ -67,7 +71,7 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult { } }, }); - const { mutate: updateTicket_v1, isLoading: ticketUpdating_v1 } = useUpdateTicket_v1({ + const { mutateAsync: updateTicket_v1, isLoading: ticketUpdating_v1 } = useUpdateTicket_v1({ onSuccess: () => { refetch_v1(); }, @@ -80,10 +84,12 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult { nid: ticket.nid, categoryId: ticket.categoryId, author: ticket.author, + authorId: ticket.authorId, groupId: ticket.groupId, assigneeId: ticket.assigneeId, status: ticket.status, title: ticket.title, + content: ticket.content, contentSafeHTML: ticket.contentSafeHTML, files: ticket.files, language: ticket.language, @@ -100,11 +106,13 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult { } }, [ticket, ticket_v1]); - const update = (data: MixedUpdateData) => { + const update = async (data: MixedUpdateData) => { if (!ticket) { return; } const updateData = pick(data, [ + 'title', + 'content', 'categoryId', 'groupId', 'assigneeId', @@ -114,10 +122,10 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult { ]); const updateData_v1 = pick(data, ['private', 'subscribed']); if (!isEmpty(updateData)) { - updateTicket([ticket.id, updateData]); + await updateTicket([ticket.id, updateData]); } if (!isEmpty(updateData_v1)) { - updateTicket_v1([ticket.id, updateData_v1]); + await updateTicket_v1([ticket.id, updateData_v1]); } }; diff --git a/next/web/src/api/ticket.ts b/next/web/src/api/ticket.ts index c015550ab..4636f4585 100644 --- a/next/web/src/api/ticket.ts +++ b/next/web/src/api/ticket.ts @@ -32,6 +32,7 @@ export interface TicketSchema { } export interface TicketDetailSchema extends TicketSchema { + content: string; contentSafeHTML: string; author?: UserSchema; assignee?: UserSchema; @@ -224,6 +225,8 @@ async function createTicket(data: CreateTicketData) { } export interface UpdateTicketData { + title?: string; + content?: string; categoryId?: string; groupId?: string | null; assigneeId?: string | null;