Переглянути джерело

Update dependencies and configuration for Tailwind CSS typography

- Added @tailwindcss/typography to package.json and yarn.lock for enhanced typography support.
- Updated globals.css to include the typography plugin.
- Modified layout.tsx to import Wangeditor styles.
- Renamed document configuration paths in menus.tsx for consistency.
- Introduced new document configuration types in api index.ts for better type management.
0es 3 місяців тому
батько
коміт
55b54a94a0

+ 1 - 0
package.json

@@ -29,6 +29,7 @@
   "devDependencies": {
     "@biomejs/biome": "2.2.0",
     "@tailwindcss/postcss": "^4",
+    "@tailwindcss/typography": "^0.5.19",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",

+ 457 - 0
src/app/(dashboard)/config/doc-config/page.tsx

@@ -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(/&nbsp;/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" }}
+    />
+  );
+}

+ 1 - 0
src/app/globals.css

@@ -1,4 +1,5 @@
 @import "tailwindcss";
+@plugin "@tailwindcss/typography";
 
 :root {
   --foreground-rgb: 0, 0, 0;

+ 1 - 0
src/app/layout.tsx

@@ -1,6 +1,7 @@
 import { AntdRegistry } from "@ant-design/nextjs-registry";
 import type { Metadata } from "next";
 import { Geist, Geist_Mono } from "next/font/google";
+import "@wangeditor/editor/dist/css/style.css";
 import "./globals.css";
 import { App, ConfigProvider } from "antd";
 import zhCN from "antd/locale/zh_CN";

+ 2 - 2
src/config/menus.tsx

@@ -147,9 +147,9 @@ export const menuConfig: MenuItem[] = [
       {
         key: "document-config",
         label: "协议文档配置",
-        path: "/config/document-config",
+        path: "/config/doc-config",
         icon: <FileOutlined />,
-        permission: "/config/document-config",
+        permission: "/config/doc-config",
       },
     ],
   },

+ 35 - 0
src/services/docConfig.ts

@@ -0,0 +1,35 @@
+/**
+ * Doc Config (协议文档配置) API services
+ */
+
+import request from "@/lib/request";
+import type {
+  DocConfigAdminDTO,
+  DocConfigAdminQuery,
+  PagerDocConfigAdminDTO,
+} from "@/types/api";
+
+export async function getDocConfigPage(
+  query: DocConfigAdminQuery
+): Promise<PagerDocConfigAdminDTO> {
+  return request("/config/doc-config/page", {
+    method: "POST",
+    body: JSON.stringify(query),
+  });
+}
+
+export async function createDocConfig(data: DocConfigAdminDTO): Promise<void> {
+  return request("/config/doc-config/create", {
+    method: "POST",
+    body: JSON.stringify(data),
+  });
+}
+
+export async function updateDocConfig(data: DocConfigAdminDTO): Promise<void> {
+  return request("/config/doc-config/update", {
+    method: "POST",
+    body: JSON.stringify(data),
+  });
+}
+
+// NOTE: delete API is currently missing on backend (暂缺)

+ 36 - 0
src/types/api/docConfig.ts

@@ -0,0 +1,36 @@
+/**
+ * Doc Config (协议文档配置) API types
+ *
+ * Note:
+ * - id is treated as string in FE.
+ * - content is an HTML string that can be rendered directly.
+ */
+
+import type { QuerySort } from "./common";
+
+export interface DocConfigAdminDTO {
+  id?: string;
+  createdAt?: number;
+  updatedAt?: number;
+  key?: string;
+  name?: string;
+  content?: string; // HTML string
+}
+
+export interface DocConfigAdminQuery {
+  pageSize?: number;
+  pageIndex?: number;
+  sort?: QuerySort;
+  key?: string;
+  name?: string;
+  skip?: number;
+}
+
+export interface PagerDocConfigAdminDTO {
+  total: number;
+  pageIndex: number;
+  pageSize: number;
+  items: DocConfigAdminDTO[];
+}
+
+

+ 7 - 0
src/types/api/index.ts

@@ -114,3 +114,10 @@ export type {
   WalletRechargeConfigAdminDTO,
   WalletRechargeConfigAdminQuery,
 } from "./wallet";
+
+// Doc config types
+export type {
+  DocConfigAdminDTO,
+  DocConfigAdminQuery,
+  PagerDocConfigAdminDTO,
+} from "./docConfig";

+ 25 - 0
yarn.lock

@@ -1405,6 +1405,13 @@
     postcss "^8.4.41"
     tailwindcss "4.1.17"
 
+"@tailwindcss/typography@^0.5.19":
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.19.tgz#ecb734af2569681eb40932f09f60c2848b909456"
+  integrity sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==
+  dependencies:
+    postcss-selector-parser "6.0.10"
+
 "@transloadit/prettier-bytes@0.0.7":
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz#cdb5399f445fdd606ed833872fa0cabdbc51686b"
@@ -1815,6 +1822,11 @@ css-to-react-native@3.2.0:
     css-color-keywords "^1.0.0"
     postcss-value-parser "^4.0.2"
 
+cssesc@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+  integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
 csstype@3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
@@ -2420,6 +2432,14 @@ picocolors@^1.0.0, picocolors@^1.1.1:
   resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
   integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
 
+postcss-selector-parser@6.0.10:
+  version "6.0.10"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
+  integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
+  dependencies:
+    cssesc "^3.0.0"
+    util-deprecate "^1.0.2"
+
 postcss-value-parser@^4.0.2:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
@@ -2732,6 +2752,11 @@ undici-types@~6.21.0:
   resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
   integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
 
+util-deprecate@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
 wildcard@^1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-1.1.2.tgz#a7020453084d8cd2efe70ba9d3696263de1710a5"