feat(ops): kb doc edit, validation auto-tickets, support batch ops
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
KnowledgeBaseDto,
|
||||
KnowledgeDocumentDetailDto,
|
||||
KnowledgeDocumentDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
@@ -49,6 +50,10 @@ export default function KnowledgeBasesPage() {
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docModalOpen, setDocModalOpen] = useState(false);
|
||||
const [docSaving, setDocSaving] = useState(false);
|
||||
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
|
||||
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
|
||||
const [editDocSaving, setEditDocSaving] = useState(false);
|
||||
const [editDocForm] = Form.useForm<DocForm>();
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||
useAdminList<KnowledgeBaseDto>(
|
||||
@@ -195,28 +200,52 @@ export default function KnowledgeBasesPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
drawerKb?.canEditFull ? (
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
if (!drawerKb) return;
|
||||
try {
|
||||
const detail = await request<KnowledgeDocumentDetailDto>(
|
||||
`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
|
||||
);
|
||||
setEditingDoc(row);
|
||||
editDocForm.setFieldsValue({
|
||||
title: detail.title,
|
||||
contentText: detail.contentText || '',
|
||||
});
|
||||
setEditDocModalOpen(true);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="删除文档?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
message.success('已删除');
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
@@ -398,6 +427,50 @@ export default function KnowledgeBasesPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑知识文档"
|
||||
open={editDocModalOpen}
|
||||
onCancel={() => {
|
||||
setEditDocModalOpen(false);
|
||||
setEditingDoc(null);
|
||||
}}
|
||||
confirmLoading={editDocSaving}
|
||||
onOk={async () => {
|
||||
if (!drawerKb || !editingDoc) return;
|
||||
const values = await editDocForm.validateFields();
|
||||
setEditDocSaving(true);
|
||||
try {
|
||||
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: values.title,
|
||||
contentText: values.contentText || null,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setEditDocModalOpen(false);
|
||||
setEditingDoc(null);
|
||||
void loadDocs(drawerKb);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setEditDocSaving(false);
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={editDocForm} layout="vertical">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contentText" label="正文(可粘贴)">
|
||||
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type BatchReviewPreviewItem,
|
||||
type BatchReviewPreviewResponse,
|
||||
type DevPlanTaskTypeDto,
|
||||
type DevPlanVersionDto,
|
||||
type SupportTicketDto,
|
||||
type SupportTicketLinkedTaskDto,
|
||||
type SupportTicketStatusDto,
|
||||
@@ -96,6 +97,10 @@ export default function SupportTicketsPage() {
|
||||
const [batchConfirming, setBatchConfirming] = useState(false);
|
||||
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
|
||||
const [batchStatusSaving, setBatchStatusSaving] = useState(false);
|
||||
const [batchCreateSaving, setBatchCreateSaving] = useState(false);
|
||||
const [batchPublishOpen, setBatchPublishOpen] = useState(false);
|
||||
const [batchPublishSaving, setBatchPublishSaving] = useState(false);
|
||||
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE');
|
||||
@@ -114,9 +119,21 @@ export default function SupportTicketsPage() {
|
||||
rejectReason?: string;
|
||||
note?: string;
|
||||
}>();
|
||||
const [batchPublishForm] = Form.useForm<{
|
||||
versionId: string;
|
||||
dispatchToWecom?: boolean;
|
||||
dispatchSupplement?: string;
|
||||
}>();
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchPublishOpen) return;
|
||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||
.then((res) => setVersions(res.items ?? []))
|
||||
.catch(() => setVersions([]));
|
||||
}, [batchPublishOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
@@ -257,6 +274,67 @@ export default function SupportTicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBatchCreateTasks() {
|
||||
if (!selectedRowKeys.length) return;
|
||||
setBatchCreateSaving(true);
|
||||
try {
|
||||
const res = await request<{ successCount: number; failCount: number }>(
|
||||
'/admin/support-tickets/batch-create-tasks',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticketIds: selectedRowKeys }),
|
||||
},
|
||||
);
|
||||
message.success(`已创建 ${res.successCount} 条任务,跳过/失败 ${res.failCount} 条`);
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '批量创建任务失败');
|
||||
} finally {
|
||||
setBatchCreateSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBatchPublish() {
|
||||
batchPublishForm.setFieldsValue({
|
||||
versionId: undefined,
|
||||
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
|
||||
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
||||
});
|
||||
setBatchPublishOpen(true);
|
||||
}
|
||||
|
||||
async function submitBatchPublish() {
|
||||
const values = await batchPublishForm.validateFields();
|
||||
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
|
||||
setBatchPublishSaving(true);
|
||||
try {
|
||||
const res = await request<{
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
linkedTaskCount: number;
|
||||
}>('/admin/support-tickets/batch-publish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketIds: selectedRowKeys,
|
||||
versionId: values.versionId,
|
||||
dispatchToWecom: !!values.dispatchToWecom,
|
||||
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success(
|
||||
`已关联 ${res.linkedTaskCount} 条任务到版本,工单成功 ${res.successCount} 条,失败 ${res.failCount} 条`,
|
||||
);
|
||||
setBatchPublishOpen(false);
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '批量发布失败');
|
||||
} finally {
|
||||
setBatchPublishSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreateTasks() {
|
||||
const values = await tasksForm.validateFields();
|
||||
const note = reviewForm.getFieldValue('note') as string | undefined;
|
||||
@@ -440,6 +518,16 @@ export default function SupportTicketsPage() {
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={batchCreateSaving}
|
||||
onClick={() => void submitBatchCreateTasks()}
|
||||
>
|
||||
一键创建任务
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||
一键发布
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</Button>
|
||||
@@ -945,6 +1033,33 @@ export default function SupportTicketsPage() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="一键发布"
|
||||
open={batchPublishOpen}
|
||||
onCancel={() => setBatchPublishOpen(false)}
|
||||
onOk={() => void submitBatchPublish()}
|
||||
confirmLoading={batchPublishSaving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
已选 {selectedRowKeys.length} 条工单。将把各工单关联的开发任务追加到所选版本;无任务的工单将跳过。
|
||||
</Typography.Paragraph>
|
||||
<Form form={batchPublishForm} layout="vertical">
|
||||
<Form.Item name="versionId" label="开发版本" rules={[{ required: true, message: '请选择开发版本' }]}>
|
||||
<Select
|
||||
placeholder="选择版本"
|
||||
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dispatchToWecom" valuePropName="checked">
|
||||
<Checkbox>同时发送到企微开发助手</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="dispatchSupplement" label="派发补充说明">
|
||||
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量 AI 审核确认"
|
||||
open={batchPreviewOpen}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { showPartnerToast } from './toast';
|
||||
|
||||
@@ -159,6 +160,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code === 401 ? 401 : json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
@@ -213,6 +215,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = res.status >= 500 ? res.status : json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportApiError } from '@dukang/client-logging';
|
||||
|
||||
export const BRAND = {
|
||||
red: '#A02D30',
|
||||
yellow: '#FFC107',
|
||||
@@ -83,6 +85,12 @@ async function rawRequest<T>(
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code;
|
||||
if (json.code === 400) {
|
||||
reportApiError(
|
||||
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
||||
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ClientApp } from '@dukang/shared-types';
|
||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||
import { resetStoresSessionBootstrap } from './stores-session';
|
||||
import { resetHomeCatalogBootstrap } from './home-catalog-session';
|
||||
import { reportClientValidationError } from './client-error';
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const origin =
|
||||
@@ -113,6 +114,12 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
||||
throw new Error('接口不可达,请确认 API 服务已启动');
|
||||
}
|
||||
if (status >= 400 || body.code !== 0) {
|
||||
if (status === 400 || body?.code === 400) {
|
||||
reportClientValidationError({
|
||||
message: body?.message || `请求失败(${status})`,
|
||||
apiPath: path,
|
||||
});
|
||||
}
|
||||
throw new Error(body?.message || `请求失败(${status})`);
|
||||
}
|
||||
return (res.data as { data: T }).data;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'validation_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
@@ -57,6 +58,17 @@ export function reportClientError(payload: ClientErrorPayload): void {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** 上报 API 400 验证错误(失败静默) */
|
||||
export function reportClientValidationError(input: { message: string; apiPath?: string }): void {
|
||||
if (input.apiPath?.includes('/common/client-errors')) return;
|
||||
reportClientError({
|
||||
level: 'warn',
|
||||
category: 'validation_error',
|
||||
message: input.message,
|
||||
extra: { status: 400, url: input.apiPath },
|
||||
});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */
|
||||
|
||||
@@ -175,15 +175,14 @@
|
||||
}
|
||||
|
||||
.store-detail-env-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.store-detail-env-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-container);
|
||||
@@ -192,8 +191,9 @@
|
||||
|
||||
.store-detail-env-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.store-detail-bar {
|
||||
|
||||
Reference in New Issue
Block a user