Browse Source

Update package.json lint command and enhance BizCategory configuration

- Modified the lint command in package.json to include TypeScript checks.
- Updated placeholder text in BizCategoryConfigPage for clarity.
- Added new menu item for BizCategory constants with appropriate permissions and icon.
- Expanded API types to include BizCategory constant types for better type safety.
0es 2 months ago
parent
commit
fa2fe34a31

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
     "dev": "next dev --port 3002",
     "build": "next build",
     "start": "next start",
-    "lint": "biome check",
+    "lint": "biome check && tsc --noEmit",
     "format": "biome format --write"
   },
   "dependencies": {

+ 1 - 1
src/app/(dashboard)/bizcategory/config/page.tsx

@@ -559,7 +559,7 @@ const BizCategoryConfigPage: React.FC = () => {
                         style={{ marginBottom: 0 }}
                       >
                         <Input
-                          placeholder="语言代码 (如: en, zh)"
+                          placeholder="en / in / zh"
                           style={{ width: 150 }}
                         />
                       </Form.Item>

+ 628 - 0
src/app/(dashboard)/bizcategory/constant/page.tsx

@@ -0,0 +1,628 @@
+"use client";
+
+import {
+  EditOutlined,
+  MinusCircleOutlined,
+  PlusOutlined,
+  ReloadOutlined,
+  SearchOutlined,
+  UndoOutlined,
+} from "@ant-design/icons";
+import {
+  App,
+  Button,
+  Descriptions,
+  Form,
+  Image,
+  Input,
+  InputNumber,
+  Modal,
+  Select,
+  Space,
+  Switch,
+  Table,
+  Tag,
+  Tooltip,
+} from "antd";
+import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
+import type React from "react";
+import { useEffect, useMemo, useState } from "react";
+import ImageUpload from "@/components/ImageUpload";
+import {
+  createBizCategoryConstant,
+  getBizCategoryConstantPage,
+  updateBizCategoryConstant,
+} from "@/services/bizCategoryConstant";
+import type {
+  BizCategoryConstantAdminDTO,
+  BizCategoryConstantAdminQuery,
+  BizCategoryConstantSub,
+  I18nTextSub,
+} from "@/types/api";
+import { formatTimestamp } from "@/utils/date";
+
+const enabledOptions = [
+  { label: "全部", value: "all" },
+  { label: "启用", value: "true" },
+  { label: "禁用", value: "false" },
+];
+
+function parseEnabled(v: unknown): boolean | undefined {
+  if (v === "true") return true;
+  if (v === "false") return false;
+  return undefined;
+}
+
+function normalizeI18ns(raw: unknown): I18nTextSub[] | undefined {
+  if (!Array.isArray(raw)) return undefined;
+  const list = raw
+    .map((it) => {
+      const obj = it as Record<string, unknown>;
+      const lang = typeof obj.lang === "string" ? obj.lang.trim() : "";
+      const text = typeof obj.text === "string" ? obj.text.trim() : "";
+      if (!lang && !text) return null;
+      return { lang, text };
+    })
+    .filter((x): x is I18nTextSub => Boolean(x));
+  return list;
+}
+
+function normalizeConstants(
+  raw: unknown,
+): BizCategoryConstantSub[] | undefined {
+  if (!Array.isArray(raw)) return undefined;
+  const list = raw
+    .map((it) => {
+      const obj = it as Record<string, unknown>;
+      const value = typeof obj.value === "string" ? obj.value.trim() : "";
+      const icon = typeof obj.icon === "string" ? obj.icon.trim() : "";
+      const rawPriority = obj.priority;
+      const priority =
+        typeof rawPriority === "number" && Number.isFinite(rawPriority)
+          ? rawPriority
+          : typeof rawPriority === "string" && rawPriority.trim() !== ""
+            ? Number(rawPriority)
+            : undefined;
+      const valueI18ns = normalizeI18ns(obj.valueI18ns);
+      if (
+        !value &&
+        !icon &&
+        typeof priority !== "number" &&
+        !valueI18ns?.length
+      ) {
+        return null;
+      }
+      const out: BizCategoryConstantSub = {};
+      if (value) out.value = value;
+      if (icon) out.icon = icon;
+      if (typeof priority === "number" && Number.isFinite(priority)) {
+        out.priority = priority;
+      }
+      if (valueI18ns?.length) out.valueI18ns = valueI18ns;
+      return out;
+    })
+    .filter((x): x is BizCategoryConstantSub => x !== null);
+  return list.length ? list : [];
+}
+
+const BizCategoryConstantPage: React.FC = () => {
+  const { message } = App.useApp();
+  const [searchForm] = Form.useForm();
+  const [editForm] = Form.useForm();
+
+  const [loading, setLoading] = useState(false);
+  const [dataSource, setDataSource] = useState<BizCategoryConstantAdminDTO[]>(
+    [],
+  );
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState<BizCategoryConstantAdminQuery>(
+    {
+      pageSize: 20,
+      pageIndex: 1,
+    },
+  );
+
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editMode, setEditMode] = useState(false);
+  const [editLoading, setEditLoading] = useState(false);
+  const [currentRecord, setCurrentRecord] =
+    useState<BizCategoryConstantAdminDTO | null>(null);
+
+  const loadPageData = async () => {
+    setLoading(true);
+    try {
+      const response = await getBizCategoryConstantPage(queryParams);
+      setDataSource(response.items || []);
+      setTotal(response.total || 0);
+    } catch (error) {
+      console.error("Failed to load bizcategory constant:", error);
+      message.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?.trim() || undefined,
+      enabled: parseEnabled(values.enabled),
+    });
+  };
+
+  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 openModal = (
+    mode: "create" | "edit",
+    record?: BizCategoryConstantAdminDTO,
+  ) => {
+    const isEdit = mode === "edit";
+    setEditMode(isEdit);
+    setCurrentRecord(record ?? null);
+    editForm.resetFields();
+
+    if (isEdit && record) {
+      editForm.setFieldsValue({
+        key: record.key,
+        name: record.name,
+        enabled: record.enabled ?? true,
+        constants: (record.constants ?? []).map((c) => ({
+          value: c.value,
+          icon: c.icon,
+          priority: c.priority ?? 0,
+          valueI18ns: (c.valueI18ns ?? []).map((i) => ({
+            lang: i.lang,
+            text: i.text,
+          })),
+        })),
+      });
+    } else {
+      editForm.setFieldsValue({
+        enabled: true,
+        constants: [
+          {
+            priority: 0,
+            valueI18ns: [],
+          },
+        ],
+      });
+    }
+
+    setModalVisible(true);
+  };
+
+  const handleAdd = () => openModal("create");
+  const handleEdit = (record: BizCategoryConstantAdminDTO) =>
+    openModal("edit", record);
+
+  const handleSubmit = async () => {
+    try {
+      const values = await editForm.validateFields();
+      setEditLoading(true);
+
+      const submitData: BizCategoryConstantAdminDTO = {
+        key: values.key?.trim(),
+        name: values.name?.trim(),
+        enabled: Boolean(values.enabled),
+        constants: normalizeConstants(values.constants),
+        createdAt: currentRecord?.createdAt,
+        updatedAt: currentRecord?.updatedAt,
+        version: currentRecord?.version,
+      };
+
+      if (editMode && currentRecord?.id) {
+        submitData.id = currentRecord.id;
+        await updateBizCategoryConstant({
+          ...currentRecord,
+          ...submitData,
+        });
+        message.success("更新成功");
+      } else {
+        delete submitData.id;
+        delete submitData.createdAt;
+        delete submitData.updatedAt;
+        delete submitData.version;
+        await createBizCategoryConstant(submitData);
+        message.success("创建成功");
+      }
+
+      setModalVisible(false);
+      loadPageData();
+    } catch (error) {
+      console.error("Failed to submit bizcategory constant:", error);
+    } finally {
+      setEditLoading(false);
+    }
+  };
+
+  const renderConstants = useMemo(() => {
+    return (record: BizCategoryConstantAdminDTO) => {
+      const list = record.constants ?? [];
+      if (!list.length) return <div className="text-gray-500">无常量配置</div>;
+
+      return (
+        <Descriptions bordered size="small" column={1}>
+          <Descriptions.Item label="常量列表">
+            <div className="space-y-3">
+              {list.map((c, idx) => {
+                const i18ns = c.valueI18ns ?? [];
+                return (
+                  <div
+                    key={`${record.id ?? record.key ?? "row"}-${idx}-${c.value ?? ""}`}
+                    className="border rounded p-3"
+                  >
+                    <div className="flex flex-wrap items-center gap-2">
+                      <div className="min-w-[220px]">
+                        <b>value:</b>
+                        <span>{c.value ?? "-"}</span>
+                      </div>
+                      <div className="min-w-[140px]">
+                        <b>priority:</b>
+                        <span>
+                          {typeof c.priority === "number" ? c.priority : "-"}
+                        </span>
+                      </div>
+                      <div className="flex items-center gap-2">
+                        <b>icon:</b>
+                        {c.icon ? (
+                          <Image
+                            src={c.icon}
+                            alt="icon"
+                            width={32}
+                            height={32}
+                            style={{ objectFit: "cover", borderRadius: 4 }}
+                          />
+                        ) : (
+                          <span>-</span>
+                        )}
+                      </div>
+                    </div>
+
+                    <div className="mt-2">
+                      <b>i18n:</b>
+                      {i18ns.length ? (
+                        <div className="mt-1 flex flex-wrap gap-2">
+                          {i18ns.map((it, i) => (
+                            <Tag key={`${it.lang}-${i}`}>
+                              {it.lang}:{it.text}
+                            </Tag>
+                          ))}
+                        </div>
+                      ) : (
+                        <span className="text-gray-500"> - </span>
+                      )}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          </Descriptions.Item>
+        </Descriptions>
+      );
+    };
+  }, []);
+
+  const columns: ColumnsType<BizCategoryConstantAdminDTO> = [
+    {
+      title: "常量Key",
+      dataIndex: "key",
+      key: "key",
+      width: 220,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "名称",
+      dataIndex: "name",
+      key: "name",
+      width: 220,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "启用",
+      dataIndex: "enabled",
+      key: "enabled",
+      width: 90,
+      render: (v?: boolean) => (
+        <Switch checked={Boolean(v)} disabled size="small" />
+      ),
+    },
+    {
+      title: "常量数",
+      key: "constantsCount",
+      width: 90,
+      render: (_, record) => record.constants?.length ?? 0,
+    },
+    {
+      title: "更新时间",
+      dataIndex: "updatedAt",
+      key: "updatedAt",
+      width: 180,
+      render: (ts?: number) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "创建时间",
+      dataIndex: "createdAt",
+      key: "createdAt",
+      width: 180,
+      render: (ts?: number) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "操作",
+      key: "action",
+      width: 120,
+      fixed: "right",
+      render: (_, record) => (
+        <Button
+          type="primary"
+          size="small"
+          icon={<EditOutlined />}
+          onClick={() => handleEdit(record)}
+        >
+          编辑
+        </Button>
+      ),
+    },
+  ];
+
+  return (
+    <div className="p-6">
+      <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="enabled" initialValue="all">
+            <Select
+              style={{ width: 160 }}
+              options={enabledOptions}
+              allowClear={false}
+            />
+          </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>
+
+      <div className="bg-white p-4 rounded-lg shadow">
+        <Table
+          columns={columns}
+          dataSource={dataSource}
+          rowKey={(record) =>
+            record.id ?? record.key ?? `${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}
+          expandable={{
+            expandedRowRender: renderConstants,
+          }}
+          scroll={{ x: 1100 }}
+          size="small"
+          bordered
+        />
+      </div>
+
+      <Modal
+        title={editMode ? "编辑品类常量" : "新增品类常量"}
+        open={modalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setModalVisible(false)}
+        confirmLoading={editLoading}
+        width={860}
+        destroyOnHidden
+        forceRender
+      >
+        <Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item
+            label="常量Key"
+            name="key"
+            rules={[
+              { required: true, message: "请输入常量Key" },
+              { max: 128, message: "Key 过长" },
+            ]}
+            tooltip={editMode ? "Key 设定后不可修改" : undefined}
+          >
+            <Input placeholder="请输入常量Key" disabled={editMode} />
+          </Form.Item>
+          <Form.Item
+            label="常量名称"
+            name="name"
+            rules={[{ required: true, message: "请输入常量名称" }]}
+          >
+            <Input placeholder="请输入常量名称" />
+          </Form.Item>
+          <Form.Item label="是否启用" name="enabled" valuePropName="checked">
+            <Switch />
+          </Form.Item>
+
+          <Form.Item
+            label="常量配置(constants)"
+            tooltip="支持列表动态新增/删除"
+          >
+            <Form.List name="constants">
+              {(fields, { add, remove }) => (
+                <div className="space-y-3">
+                  {fields.map((field) => (
+                    <div
+                      key={field.key}
+                      className="border rounded p-4 bg-gray-50"
+                    >
+                      <div className="flex items-start justify-between gap-2">
+                        <div className="flex-1">
+                          <div className="grid grid-cols-2 gap-3">
+                            <Form.Item
+                              label="常量值"
+                              name={[field.name, "value"]}
+                              rules={[
+                                { required: true, message: "请输入常量值" },
+                              ]}
+                            >
+                              <Input placeholder="请输入常量值" />
+                            </Form.Item>
+                            <Form.Item
+                              label="排序优先级"
+                              name={[field.name, "priority"]}
+                            >
+                              <InputNumber
+                                style={{ width: "100%" }}
+                                min={0}
+                                step={1}
+                                placeholder="排序"
+                              />
+                            </Form.Item>
+                          </div>
+
+                          <Form.Item label="图标" name={[field.name, "icon"]}>
+                            <ImageUpload
+                              maxCount={1}
+                              dir="bizcategory/constant/icon"
+                            />
+                          </Form.Item>
+
+                          <Form.Item
+                            label="国际化"
+                            tooltip="可选;用于不同语言的显示文本"
+                          >
+                            <Form.List name={[field.name, "valueI18ns"]}>
+                              {(i18nFields, i18nOps) => (
+                                <div className="space-y-2">
+                                  {i18nFields.map((i18nField) => (
+                                    <div
+                                      key={i18nField.key}
+                                      className="flex gap-2 items-start"
+                                    >
+                                      <Form.Item
+                                        label="语言代码"
+                                        name={[i18nField.name, "lang"]}
+                                        className="mb-0 flex-1"
+                                        rules={[
+                                          {
+                                            required: true,
+                                            message: "请输入语言代码",
+                                          },
+                                        ]}
+                                      >
+                                        <Input placeholder="en / in / zh" />
+                                      </Form.Item>
+                                      <Form.Item
+                                        label="文本"
+                                        name={[i18nField.name, "text"]}
+                                        className="mb-0 flex-2"
+                                        rules={[
+                                          {
+                                            required: true,
+                                            message: "请输入文本",
+                                          },
+                                        ]}
+                                      >
+                                        <Input placeholder="请输入文本" />
+                                      </Form.Item>
+                                      <Tooltip title="删除 i18n">
+                                        <Button
+                                          danger
+                                          type="text"
+                                          icon={<MinusCircleOutlined />}
+                                          onClick={() =>
+                                            i18nOps.remove(i18nField.name)
+                                          }
+                                        />
+                                      </Tooltip>
+                                    </div>
+                                  ))}
+                                  <Button
+                                    type="dashed"
+                                    icon={<PlusOutlined />}
+                                    onClick={() => i18nOps.add({})}
+                                  >
+                                    新增国际化文本
+                                  </Button>
+                                </div>
+                              )}
+                            </Form.List>
+                          </Form.Item>
+                        </div>
+
+                        <Tooltip title="删除该常量项">
+                          <Button
+                            danger
+                            type="text"
+                            icon={<MinusCircleOutlined />}
+                            onClick={() => remove(field.name)}
+                          />
+                        </Tooltip>
+                      </div>
+                    </div>
+                  ))}
+
+                  <Button
+                    type="dashed"
+                    icon={<PlusOutlined />}
+                    onClick={() => add({ priority: 0, valueI18ns: [] })}
+                    block
+                  >
+                    新增常量项
+                  </Button>
+                </div>
+              )}
+            </Form.List>
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default BizCategoryConstantPage;

+ 9 - 0
src/config/menus.tsx

@@ -7,6 +7,7 @@ import {
   FileOutlined,
   GlobalOutlined,
   HomeOutlined,
+  KeyOutlined,
   MessageOutlined,
   ProductOutlined,
   SettingOutlined,
@@ -46,6 +47,14 @@ export const menuConfig: MenuItem[] = [
         sortOrder: 1,
         permission: "/bizcategory/config",
       },
+      {
+        key: "bizcategory-constant",
+        label: "品类常量",
+        path: "/bizcategory/constant",
+        icon: <KeyOutlined />,
+        sortOrder: 2,
+        permission: "/bizcategory/constant",
+      },
     ],
   },
   {

+ 37 - 0
src/services/bizCategoryConstant.ts

@@ -0,0 +1,37 @@
+/**
+ * Biz Category Constant API services
+ */
+
+import request from "@/lib/request";
+import type {
+  BizCategoryConstantAdminDTO,
+  BizCategoryConstantAdminQuery,
+  PagerBizCategoryConstantAdminDTO,
+} from "@/types/api";
+
+export async function getBizCategoryConstantPage(
+  query: BizCategoryConstantAdminQuery,
+): Promise<PagerBizCategoryConstantAdminDTO> {
+  return request("/config/bizcategory-constant/page", {
+    method: "POST",
+    body: JSON.stringify(query),
+  });
+}
+
+export async function createBizCategoryConstant(
+  data: BizCategoryConstantAdminDTO,
+): Promise<unknown> {
+  return request("/config/bizcategory-constant/create", {
+    method: "POST",
+    body: JSON.stringify(data),
+  });
+}
+
+export async function updateBizCategoryConstant(
+  data: BizCategoryConstantAdminDTO,
+): Promise<unknown> {
+  return request("/config/bizcategory-constant/update", {
+    method: "POST",
+    body: JSON.stringify(data),
+  });
+}

+ 55 - 0
src/types/api/bizCategoryConstant.ts

@@ -0,0 +1,55 @@
+/**
+ * Biz Category Constant (品类常量配置) API types
+ *
+ * Note:
+ * - Backend declares ObjectId; FE treats it as string.
+ */
+
+import type { I18nTextSub } from "./bizCategory";
+import type { QuerySort } from "./common";
+
+export interface BizCategoryConstantSub {
+  value?: string;
+  icon?: string;
+  priority?: number;
+  valueI18ns?: I18nTextSub[];
+}
+
+export interface BizCategoryConstantAdminDTO {
+  id?: string;
+  createdAt?: number;
+  updatedAt?: number;
+  version?: number;
+  /**
+   * 常量 key,唯一,设定后不可修改
+   */
+  key?: string;
+  /**
+   * 常量名称
+   */
+  name?: string;
+  /**
+   * 是否启用
+   */
+  enabled?: boolean;
+  /**
+   * 常量具体配置
+   */
+  constants?: BizCategoryConstantSub[];
+}
+
+export interface BizCategoryConstantAdminQuery {
+  pageSize?: number;
+  pageIndex?: number;
+  sort?: QuerySort;
+  enabled?: boolean;
+  key?: string;
+  skip?: number;
+}
+
+export interface PagerBizCategoryConstantAdminDTO {
+  total: number;
+  pageIndex: number;
+  pageSize: number;
+  items: BizCategoryConstantAdminDTO[];
+}

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

@@ -71,6 +71,13 @@ export type {
   BizCategoryConfigListResponse,
   I18nTextSub,
 } from "./bizCategory";
+// Biz Category constant types
+export type {
+  BizCategoryConstantAdminDTO,
+  BizCategoryConstantAdminQuery,
+  BizCategoryConstantSub,
+  PagerBizCategoryConstantAdminDTO,
+} from "./bizCategoryConstant";
 // Common types
 export type {
   ListResponse,