|
|
@@ -0,0 +1,457 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import {
|
|
|
+ EditOutlined,
|
|
|
+ EyeOutlined,
|
|
|
+ PlusOutlined,
|
|
|
+ ReloadOutlined,
|
|
|
+ SearchOutlined,
|
|
|
+ UndoOutlined,
|
|
|
+} from "@ant-design/icons";
|
|
|
+import type { IDomEditor } from "@wangeditor/editor";
|
|
|
+import { App, Button, Form, Input, Modal, Space, Table } from "antd";
|
|
|
+import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
|
|
+import dynamic from "next/dynamic";
|
|
|
+import type React from "react";
|
|
|
+import { useEffect, useState } from "react";
|
|
|
+import {
|
|
|
+ createDocConfig,
|
|
|
+ getDocConfigPage,
|
|
|
+ updateDocConfig,
|
|
|
+} from "@/services/docConfig";
|
|
|
+import type { DocConfigAdminDTO, DocConfigAdminQuery } from "@/types/api";
|
|
|
+import { formatTimestamp } from "@/utils/date";
|
|
|
+
|
|
|
+function normalizeId(id: unknown): string | undefined {
|
|
|
+ if (typeof id === "string") return id;
|
|
|
+ if (id && typeof id === "object") {
|
|
|
+ const maybe = id as Record<string, unknown>;
|
|
|
+ if (typeof maybe._id === "string") return maybe._id;
|
|
|
+ if (typeof maybe.id === "string") return maybe.id;
|
|
|
+ if (typeof maybe.date === "string") return maybe.date;
|
|
|
+ if (typeof maybe.timestamp === "number") return String(maybe.timestamp);
|
|
|
+ try {
|
|
|
+ return JSON.stringify(id);
|
|
|
+ } catch {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function isEmptyHtml(html: string): boolean {
|
|
|
+ const text = html
|
|
|
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
|
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
|
+ .replace(/<[^>]+>/g, "")
|
|
|
+ .replace(/ /g, " ")
|
|
|
+ .trim();
|
|
|
+ return text.length === 0;
|
|
|
+}
|
|
|
+
|
|
|
+const DocConfigPage: React.FC = () => {
|
|
|
+ const { message } = App.useApp();
|
|
|
+ const [searchForm] = Form.useForm();
|
|
|
+ const [editForm] = Form.useForm();
|
|
|
+
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+ const [dataSource, setDataSource] = useState<DocConfigAdminDTO[]>([]);
|
|
|
+ const [total, setTotal] = useState(0);
|
|
|
+ const [queryParams, setQueryParams] = useState<DocConfigAdminQuery>({
|
|
|
+ pageSize: 20,
|
|
|
+ pageIndex: 1,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Modal state
|
|
|
+ const [modalVisible, setModalVisible] = useState(false);
|
|
|
+ const [previewVisible, setPreviewVisible] = useState(false);
|
|
|
+ const [previewHtml, setPreviewHtml] = useState<string>("");
|
|
|
+ const [editMode, setEditMode] = useState(false);
|
|
|
+ const [editLoading, setEditLoading] = useState(false);
|
|
|
+ const [currentEditId, setCurrentEditId] = useState<string | undefined>(
|
|
|
+ undefined,
|
|
|
+ );
|
|
|
+
|
|
|
+ // Editor state
|
|
|
+ const [editor, setEditor] = useState<IDomEditor | null>(null);
|
|
|
+ const [html, setHtml] = useState<string>("");
|
|
|
+
|
|
|
+ // Cleanup editor
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ if (editor) {
|
|
|
+ editor.destroy();
|
|
|
+ setEditor(null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [editor]);
|
|
|
+
|
|
|
+ const loadPageData = async () => {
|
|
|
+ setLoading(true);
|
|
|
+ try {
|
|
|
+ const response = await getDocConfigPage(queryParams);
|
|
|
+ const items = (response.items || []).map((it) => ({
|
|
|
+ ...it,
|
|
|
+ id: normalizeId(it.id),
|
|
|
+ }));
|
|
|
+ setDataSource(items);
|
|
|
+ setTotal(response.total || 0);
|
|
|
+ } catch (error) {
|
|
|
+ // request.ts already shows toast
|
|
|
+ console.error("Failed to load doc config:", error);
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // biome-ignore lint/correctness/useExhaustiveDependencies: loadPageData is stable and doesn't need to be in dependencies
|
|
|
+ useEffect(() => {
|
|
|
+ loadPageData();
|
|
|
+ }, [queryParams]);
|
|
|
+
|
|
|
+ const handleSearch = () => {
|
|
|
+ const values = searchForm.getFieldsValue();
|
|
|
+ setQueryParams({
|
|
|
+ ...queryParams,
|
|
|
+ pageIndex: 1,
|
|
|
+ key: values.key || undefined,
|
|
|
+ name: values.name || undefined,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleReset = () => {
|
|
|
+ searchForm.resetFields();
|
|
|
+ setQueryParams({
|
|
|
+ pageSize: 20,
|
|
|
+ pageIndex: 1,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleRefresh = () => {
|
|
|
+ loadPageData();
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTableChange = (pagination: TablePaginationConfig) => {
|
|
|
+ setQueryParams({
|
|
|
+ ...queryParams,
|
|
|
+ pageIndex: pagination.current || 1,
|
|
|
+ pageSize: pagination.pageSize || 20,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleAdd = () => {
|
|
|
+ setEditMode(false);
|
|
|
+ setCurrentEditId(undefined);
|
|
|
+ editForm.resetFields();
|
|
|
+ setHtml("");
|
|
|
+ setModalVisible(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleEdit = (record: DocConfigAdminDTO) => {
|
|
|
+ setEditMode(true);
|
|
|
+ setCurrentEditId(record.id);
|
|
|
+ editForm.resetFields();
|
|
|
+ editForm.setFieldsValue({
|
|
|
+ key: record.key,
|
|
|
+ name: record.name,
|
|
|
+ });
|
|
|
+ setHtml(record.content || "");
|
|
|
+ setModalVisible(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handlePreview = (record: DocConfigAdminDTO) => {
|
|
|
+ setPreviewHtml(record.content || "");
|
|
|
+ setPreviewVisible(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSubmit = async () => {
|
|
|
+ try {
|
|
|
+ const values = await editForm.validateFields();
|
|
|
+ const submitHtml = html || "";
|
|
|
+ if (!submitHtml || isEmptyHtml(submitHtml)) {
|
|
|
+ message.error("内容不能为空");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setEditLoading(true);
|
|
|
+
|
|
|
+ const submitData: DocConfigAdminDTO = {
|
|
|
+ id: currentEditId,
|
|
|
+ key: values.key,
|
|
|
+ name: values.name,
|
|
|
+ content: submitHtml,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (editMode && currentEditId) {
|
|
|
+ await updateDocConfig(submitData);
|
|
|
+ message.success("更新成功");
|
|
|
+ } else {
|
|
|
+ delete submitData.id;
|
|
|
+ await createDocConfig(submitData);
|
|
|
+ message.success("创建成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ setModalVisible(false);
|
|
|
+ loadPageData();
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Failed to submit doc config:", error);
|
|
|
+ } finally {
|
|
|
+ setEditLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const columns: ColumnsType<DocConfigAdminDTO> = [
|
|
|
+ {
|
|
|
+ title: "KEY",
|
|
|
+ dataIndex: "key",
|
|
|
+ key: "key",
|
|
|
+ width: 220,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "名称",
|
|
|
+ dataIndex: "name",
|
|
|
+ key: "name",
|
|
|
+ width: 240,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "更新时间",
|
|
|
+ dataIndex: "updatedAt",
|
|
|
+ key: "updatedAt",
|
|
|
+ width: 180,
|
|
|
+ render: (timestamp: number | undefined) =>
|
|
|
+ timestamp ? formatTimestamp(timestamp) : "-",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "创建时间",
|
|
|
+ dataIndex: "createdAt",
|
|
|
+ key: "createdAt",
|
|
|
+ width: 180,
|
|
|
+ render: (timestamp: number | undefined) =>
|
|
|
+ timestamp ? formatTimestamp(timestamp) : "-",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: "操作",
|
|
|
+ key: "action",
|
|
|
+ width: 200,
|
|
|
+ fixed: "right",
|
|
|
+ render: (_, record) => (
|
|
|
+ <Space size="small">
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ icon={<EditOutlined />}
|
|
|
+ onClick={() => handleEdit(record)}
|
|
|
+ >
|
|
|
+ 编辑
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ icon={<EyeOutlined />}
|
|
|
+ onClick={() => handlePreview(record)}
|
|
|
+ >
|
|
|
+ 预览
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="p-6">
|
|
|
+ {/* Search Form */}
|
|
|
+ <div className="bg-white p-4 rounded-lg shadow mb-4">
|
|
|
+ <Form form={searchForm} layout="inline" className="gap-x-2 gap-y-4">
|
|
|
+ <Form.Item label="KEY" name="key">
|
|
|
+ <Input placeholder="请输入" allowClear style={{ width: 220 }} />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label="名称" name="name">
|
|
|
+ <Input placeholder="请输入" allowClear style={{ width: 220 }} />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item style={{ marginLeft: "auto" }}>
|
|
|
+ <Space>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<SearchOutlined />}
|
|
|
+ onClick={handleSearch}
|
|
|
+ >
|
|
|
+ 搜索
|
|
|
+ </Button>
|
|
|
+ <Button icon={<UndoOutlined />} onClick={handleReset}>
|
|
|
+ 重置
|
|
|
+ </Button>
|
|
|
+ <Button icon={<ReloadOutlined />} onClick={handleRefresh}>
|
|
|
+ 刷新
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<PlusOutlined />}
|
|
|
+ onClick={handleAdd}
|
|
|
+ style={{ backgroundColor: "#52c41a" }}
|
|
|
+ >
|
|
|
+ 新增
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ </Form.Item>
|
|
|
+ </Form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Table */}
|
|
|
+ <div className="bg-white p-4 rounded-lg shadow">
|
|
|
+ <Table
|
|
|
+ columns={columns}
|
|
|
+ dataSource={dataSource}
|
|
|
+ rowKey={(record) =>
|
|
|
+ record.id ||
|
|
|
+ record.key ||
|
|
|
+ `${record.name ?? ""}-${record.createdAt ?? ""}`
|
|
|
+ }
|
|
|
+ loading={loading}
|
|
|
+ pagination={{
|
|
|
+ current: queryParams.pageIndex,
|
|
|
+ pageSize: queryParams.pageSize,
|
|
|
+ total,
|
|
|
+ showSizeChanger: true,
|
|
|
+ showTotal: (t) => `共 ${t} 条`,
|
|
|
+ pageSizeOptions: ["10", "20", "30", "40", "50", "100", "200"],
|
|
|
+ }}
|
|
|
+ onChange={handleTableChange}
|
|
|
+ scroll={{ x: 1100 }}
|
|
|
+ size="small"
|
|
|
+ bordered
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Edit/Add Modal */}
|
|
|
+ <Modal
|
|
|
+ title={editMode ? "编辑协议文档" : "新增协议文档"}
|
|
|
+ open={modalVisible}
|
|
|
+ onOk={handleSubmit}
|
|
|
+ onCancel={() => setModalVisible(false)}
|
|
|
+ confirmLoading={editLoading}
|
|
|
+ width={980}
|
|
|
+ destroyOnHidden
|
|
|
+ >
|
|
|
+ <Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
|
+ <Form.Item
|
|
|
+ label="KEY"
|
|
|
+ name="key"
|
|
|
+ rules={[{ required: true, message: "该项不能空" }]}
|
|
|
+ >
|
|
|
+ <Input placeholder="请输入 KEY" />
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item
|
|
|
+ label="名称"
|
|
|
+ name="name"
|
|
|
+ rules={[{ required: true, message: "该项不能空" }]}
|
|
|
+ >
|
|
|
+ <Input placeholder="请输入名称" />
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
+ <Form.Item label="内容" required>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ border: "1px solid #d9d9d9",
|
|
|
+ borderRadius: 8,
|
|
|
+ overflow: "hidden",
|
|
|
+ maxWidth: "100%",
|
|
|
+ }}
|
|
|
+ className="prose lg:prose-xl w-full"
|
|
|
+ >
|
|
|
+ <div style={{ borderBottom: "1px solid #f0f0f0" }}>
|
|
|
+ <WangEditorToolbar editor={editor} />
|
|
|
+ </div>
|
|
|
+ <div style={{ height: 420 }}>
|
|
|
+ <WangEditor
|
|
|
+ value={html}
|
|
|
+ onCreated={setEditor}
|
|
|
+ onChange={setHtml}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Form.Item>
|
|
|
+ </Form>
|
|
|
+ </Modal>
|
|
|
+
|
|
|
+ {/* Preview Modal */}
|
|
|
+ <Modal
|
|
|
+ title="内容预览"
|
|
|
+ open={previewVisible}
|
|
|
+ onOk={() => setPreviewVisible(false)}
|
|
|
+ onCancel={() => setPreviewVisible(false)}
|
|
|
+ width={980}
|
|
|
+ destroyOnHidden
|
|
|
+ okText="关闭"
|
|
|
+ cancelButtonProps={{ style: { display: "none" } }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ border: "1px solid #f0f0f0",
|
|
|
+ borderRadius: 8,
|
|
|
+ padding: 16,
|
|
|
+ maxWidth: "100%",
|
|
|
+ maxHeight: "70vh",
|
|
|
+ overflow: "auto",
|
|
|
+ background: "#fff",
|
|
|
+ }}
|
|
|
+ className="prose lg:prose-xl"
|
|
|
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: content is trusted HTML from backend per requirement
|
|
|
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
|
+ />
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default DocConfigPage;
|
|
|
+
|
|
|
+/**
|
|
|
+ * WangEditor wrappers:
|
|
|
+ * - Use `next/dynamic` to avoid SSR issues (wangeditor depends on DOM APIs).
|
|
|
+ * - The CSS is imported globally in `src/app/layout.tsx`.
|
|
|
+ */
|
|
|
+const WangEditorReact = {
|
|
|
+ Editor: dynamic(
|
|
|
+ () => import("@wangeditor/editor-for-react").then((m) => m.Editor),
|
|
|
+ { ssr: false },
|
|
|
+ ),
|
|
|
+ Toolbar: dynamic(
|
|
|
+ () => import("@wangeditor/editor-for-react").then((m) => m.Toolbar),
|
|
|
+ { ssr: false },
|
|
|
+ ),
|
|
|
+};
|
|
|
+
|
|
|
+function WangEditorToolbar({ editor }: { editor: IDomEditor | null }) {
|
|
|
+ return (
|
|
|
+ <WangEditorReact.Toolbar
|
|
|
+ editor={editor}
|
|
|
+ defaultConfig={{
|
|
|
+ excludeKeys: ["fullScreen"],
|
|
|
+ }}
|
|
|
+ mode="default"
|
|
|
+ style={{ borderBottom: "none" }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function WangEditor({
|
|
|
+ value,
|
|
|
+ onCreated,
|
|
|
+ onChange,
|
|
|
+}: {
|
|
|
+ value: string;
|
|
|
+ onCreated: (editor: IDomEditor) => void;
|
|
|
+ onChange: (html: string) => void;
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <WangEditorReact.Editor
|
|
|
+ defaultConfig={{
|
|
|
+ placeholder: "请输入内容...",
|
|
|
+ }}
|
|
|
+ value={value}
|
|
|
+ onCreated={onCreated}
|
|
|
+ onChange={(ed: IDomEditor) => onChange(ed.getHtml())}
|
|
|
+ mode="default"
|
|
|
+ style={{ height: "100%", overflowY: "hidden" }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+}
|