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;