فهرست منبع

Add operation management menu item and banner config types

- Introduced a new menu item for operation management in menus.tsx, including a child item for banner configuration with appropriate permissions.
- Added new type exports for banner configuration in api index.ts to support the new menu item.
0es 2 ماه پیش
والد
کامیت
1f4e1bb564
5فایلهای تغییر یافته به همراه715 افزوده شده و 0 حذف شده
  1. 564 0
      src/app/(dashboard)/operation/banner-config/page.tsx
  2. 17 0
      src/config/menus.tsx
  3. 51 0
      src/services/bannerConfig.ts
  4. 75 0
      src/types/api/bannerConfig.ts
  5. 8 0
      src/types/api/index.ts

+ 564 - 0
src/app/(dashboard)/operation/banner-config/page.tsx

@@ -0,0 +1,564 @@
+"use client";
+
+import {
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  ReloadOutlined,
+  SearchOutlined,
+  UndoOutlined,
+} from "@ant-design/icons";
+import {
+  App,
+  Button,
+  DatePicker,
+  Form,
+  Image,
+  Input,
+  InputNumber,
+  Modal,
+  Popconfirm,
+  Select,
+  Space,
+  Switch,
+  Table,
+} 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 {
+  createBannerConfig,
+  deleteBannerConfigs,
+  getBannerConfigPage,
+  updateBannerConfig,
+} from "@/services/bannerConfig";
+import type { BannerConfigAdminDTO, BannerConfigAdminQuery } from "@/types/api";
+import { formatTimestamp } from "@/utils/date";
+import dayjs from "@/utils/dayjs";
+
+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.$oid === "string") return maybe.$oid;
+    if (typeof maybe.oid === "string") return maybe.oid;
+    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;
+}
+
+type TableRow = BannerConfigAdminDTO & {
+  _rawId?: unknown;
+  _idStr?: string;
+};
+
+const platformOptions = [
+  { label: "全部", value: 0 },
+  { label: "安卓", value: 1 },
+  { label: "苹果", value: 2 },
+  { label: "浏览器", value: 3 },
+  { label: "电脑应用", value: 4 },
+];
+
+const BannerConfigPage: React.FC = () => {
+  const { message } = App.useApp();
+  const [searchForm] = Form.useForm();
+  const [editForm] = Form.useForm();
+
+  // List state
+  const [loading, setLoading] = useState(false);
+  const [dataSource, setDataSource] = useState<TableRow[]>([]);
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState<BannerConfigAdminQuery>({
+    pageSize: 20,
+    pageIndex: 1,
+  });
+
+  // Modal state
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editMode, setEditMode] = useState(false);
+  const [editLoading, setEditLoading] = useState(false);
+  const [currentRecord, setCurrentRecord] = useState<TableRow | null>(null);
+
+  const platformLabel = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const it of platformOptions) map.set(it.value, it.label);
+    return (v?: number) => (typeof v === "number" ? (map.get(v) ?? "-") : "-");
+  }, []);
+
+  const loadPageData = async () => {
+    setLoading(true);
+    try {
+      const response = await getBannerConfigPage(queryParams);
+      const items: TableRow[] = (response.items || []).map((it) => {
+        const idStr = normalizeId(it.id);
+        return {
+          ...it,
+          _rawId: it.id,
+          _idStr: idStr,
+          id: (idStr ?? it.id) as unknown as typeof it.id,
+        };
+      });
+      setDataSource(items);
+      setTotal(response.total || 0);
+    } catch (error) {
+      console.error("Failed to load banner config:", error);
+      message.error("加载 Banner 配置失败");
+    } 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,
+      title: values.title?.trim() || undefined,
+      platform:
+        typeof values.platform === "number" ||
+        typeof values.platform === "string"
+          ? Number(values.platform)
+          : 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 openModal = (mode: "create" | "edit", record?: TableRow) => {
+    const isEdit = mode === "edit";
+    setEditMode(isEdit);
+    setCurrentRecord(record ?? null);
+    editForm.resetFields();
+
+    if (isEdit && record) {
+      editForm.setFieldsValue({
+        title: record.title,
+        subTitle: record.subTitle,
+        imageUrl: record.imageUrl,
+        platform:
+          typeof record.platform === "number" ? record.platform : undefined,
+        adSlots: (record.adSlots ?? []).map((n) => String(n)),
+        allAdSlotEffective: record.allAdSlotEffective ?? false,
+        jumpTarget: record.jumpTarget,
+        jumpLink: record.jumpLink,
+        priority: record.priority,
+        startTime: record.startTime ? dayjs(record.startTime) : undefined,
+        endTime: record.endTime ? dayjs(record.endTime) : undefined,
+        minVersion: record.minVersion,
+        maxVersion: record.maxVersion,
+      });
+    } else {
+      editForm.setFieldsValue({
+        platform: 0,
+        allAdSlotEffective: false,
+        adSlots: [],
+        priority: 0,
+      });
+    }
+
+    setModalVisible(true);
+  };
+
+  const handleAdd = () => openModal("create");
+  const handleEdit = (record: TableRow) => openModal("edit", record);
+
+  const parseAdSlots = (raw: unknown): number[] | undefined => {
+    if (!Array.isArray(raw)) return undefined;
+    const nums = raw
+      .map((it) => {
+        if (typeof it === "number") return it;
+        if (typeof it === "string" && it.trim() !== "") return Number(it);
+        return NaN;
+      })
+      .filter((n) => Number.isFinite(n));
+    return nums.length > 0 ? nums : [];
+  };
+
+  const handleSubmit = async () => {
+    try {
+      const values = await editForm.validateFields();
+      setEditLoading(true);
+
+      const submitData: BannerConfigAdminDTO = {
+        id: (currentRecord?._rawId ?? currentRecord?.id) as string,
+        title: values.title,
+        subTitle: values.subTitle,
+        imageUrl: values.imageUrl,
+        platform:
+          typeof values.platform === "number" ||
+          typeof values.platform === "string"
+            ? Number(values.platform)
+            : undefined,
+        adSlots: parseAdSlots(values.adSlots),
+        allAdSlotEffective: Boolean(values.allAdSlotEffective),
+        jumpTarget:
+          typeof values.jumpTarget === "number" ||
+          typeof values.jumpTarget === "string"
+            ? Number(values.jumpTarget)
+            : undefined,
+        jumpLink: values.jumpLink?.trim() || undefined,
+        priority:
+          typeof values.priority === "number" ||
+          typeof values.priority === "string"
+            ? Number(values.priority)
+            : undefined,
+        startTime: values.startTime
+          ? dayjs(values.startTime).valueOf()
+          : undefined,
+        endTime: values.endTime ? dayjs(values.endTime).valueOf() : undefined,
+        minVersion: values.minVersion?.trim() || undefined,
+        maxVersion: values.maxVersion?.trim() || undefined,
+        createdAt: currentRecord?.createdAt,
+        updatedAt: currentRecord?.updatedAt,
+      };
+
+      if (editMode && (currentRecord?._rawId ?? currentRecord?.id)) {
+        await updateBannerConfig(submitData);
+        message.success("更新成功");
+      } else {
+        delete submitData.id;
+        delete submitData.createdAt;
+        delete submitData.updatedAt;
+        await createBannerConfig(submitData);
+        message.success("创建成功");
+      }
+
+      setModalVisible(false);
+      loadPageData();
+    } catch (error) {
+      console.error("Failed to submit banner config:", error);
+    } finally {
+      setEditLoading(false);
+    }
+  };
+
+  const handleDelete = async (record: TableRow) => {
+    const rawId = record._rawId ?? record.id;
+    if (!rawId) {
+      message.warning("缺少记录 ID,无法删除");
+      return;
+    }
+    try {
+      await deleteBannerConfigs({ ids: [rawId as string] });
+      message.success("删除成功");
+      loadPageData();
+    } catch (error) {
+      console.error("Failed to delete banner config:", error);
+    }
+  };
+
+  const columns: ColumnsType<TableRow> = [
+    { title: "标题", dataIndex: "title", key: "title", width: 220 },
+    {
+      title: "副标题",
+      dataIndex: "subTitle",
+      key: "subTitle",
+      width: 220,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "图片",
+      dataIndex: "imageUrl",
+      key: "imageUrl",
+      width: 120,
+      render: (url?: string) =>
+        url ? <Image src={url} alt="banner" width={60} height={60} /> : "-",
+    },
+    {
+      title: "平台",
+      dataIndex: "platform",
+      key: "platform",
+      width: 110,
+      render: (v?: number) => platformLabel(v),
+    },
+    {
+      title: "广告位",
+      dataIndex: "adSlots",
+      key: "adSlots",
+      width: 160,
+      render: (v?: number[]) => (v ? (v.length ? v.join(", ") : "-") : "-"),
+    },
+    {
+      title: "广告位是否有效",
+      dataIndex: "allAdSlotEffective",
+      key: "allAdSlotEffective",
+      width: 140,
+      render: (v?: boolean) => (v ? "是" : "否"),
+    },
+    {
+      title: "跳转目标",
+      dataIndex: "jumpTarget",
+      key: "jumpTarget",
+      width: 110,
+      render: (v?: number) => (typeof v === "number" ? v : "-"),
+    },
+    {
+      title: "跳转链接",
+      dataIndex: "jumpLink",
+      key: "jumpLink",
+      width: 260,
+      ellipsis: true,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "优先级",
+      dataIndex: "priority",
+      key: "priority",
+      width: 90,
+      render: (v?: number) => (typeof v === "number" ? v : "-"),
+    },
+    {
+      title: "生效时间",
+      dataIndex: "startTime",
+      key: "startTime",
+      width: 180,
+      render: (ts: number | undefined) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "失效时间",
+      dataIndex: "endTime",
+      key: "endTime",
+      width: 180,
+      render: (ts: number | undefined) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "最低版本",
+      dataIndex: "minVersion",
+      key: "minVersion",
+      width: 120,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "最高版本",
+      dataIndex: "maxVersion",
+      key: "maxVersion",
+      width: 120,
+      render: (v?: string) => v || "-",
+    },
+    {
+      title: "创建时间",
+      dataIndex: "createdAt",
+      key: "createdAt",
+      width: 180,
+      render: (ts: number | undefined) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "更新时间",
+      dataIndex: "updatedAt",
+      key: "updatedAt",
+      width: 180,
+      render: (ts: number | undefined) => (ts ? formatTimestamp(ts) : "-"),
+    },
+    {
+      title: "操作",
+      key: "action",
+      width: 160,
+      fixed: "right",
+      render: (_, record) => (
+        <Space size="small">
+          <Button
+            type="primary"
+            size="small"
+            icon={<EditOutlined />}
+            onClick={() => handleEdit(record)}
+          >
+            编辑
+          </Button>
+          <Popconfirm
+            title="确定删除该 Banner 吗?"
+            okText="确定"
+            cancelText="取消"
+            onConfirm={() => handleDelete(record)}
+          >
+            <Button
+              type="primary"
+              danger
+              size="small"
+              icon={<DeleteOutlined />}
+            >
+              删除
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  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="标题" name="title">
+            <Input placeholder="请输入" allowClear style={{ width: 220 }} />
+          </Form.Item>
+          <Form.Item label="平台" name="platform">
+            <Select
+              placeholder="请选择"
+              allowClear
+              style={{ width: 180 }}
+              options={platformOptions}
+            />
+          </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._idStr ||
+            normalizeId(record.id) ||
+            `${record.title ?? ""}-${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: 2100 }}
+          size="small"
+          bordered
+        />
+      </div>
+
+      <Modal
+        title={editMode ? "编辑 Banner 配置" : "新增 Banner 配置"}
+        open={modalVisible}
+        onOk={handleSubmit}
+        onCancel={() => setModalVisible(false)}
+        confirmLoading={editLoading}
+        width={720}
+        destroyOnHidden
+        forceRender
+      >
+        <Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item
+            label="标题"
+            name="title"
+            rules={[{ required: true, message: "请输入标题" }]}
+          >
+            <Input placeholder="请输入标题" />
+          </Form.Item>
+          <Form.Item label="副标题" name="subTitle">
+            <Input placeholder="请输入副标题" />
+          </Form.Item>
+          <Form.Item
+            label="图片"
+            name="imageUrl"
+            rules={[{ required: true, message: "请上传图片" }]}
+          >
+            <ImageUpload dir="banner" />
+          </Form.Item>
+          <Form.Item label="平台" name="platform">
+            <Select placeholder="请选择" options={platformOptions} />
+          </Form.Item>
+          <Form.Item
+            label="广告位列表"
+            name="adSlots"
+            tooltip="支持输入多个数字,回车分隔"
+          >
+            <Select
+              mode="tags"
+              tokenSeparators={[",", " ", "\n", "\t"]}
+              placeholder="例如:1,2,3"
+            />
+          </Form.Item>
+          <Form.Item
+            label="是否所有广告位有效"
+            name="allAdSlotEffective"
+            valuePropName="checked"
+          >
+            <Switch />
+          </Form.Item>
+          <Form.Item label="跳转目标(jumpTarget)" name="jumpTarget">
+            <InputNumber style={{ width: "100%" }} min={0} step={1} />
+          </Form.Item>
+          <Form.Item label="跳转链接(jumpLink)" name="jumpLink">
+            <Input placeholder="请输入跳转链接" />
+          </Form.Item>
+          <Form.Item label="优先级" name="priority">
+            <InputNumber style={{ width: "100%" }} min={0} step={1} />
+          </Form.Item>
+          <Form.Item label="生效时间" name="startTime">
+            <DatePicker showTime style={{ width: "100%" }} />
+          </Form.Item>
+          <Form.Item label="失效时间" name="endTime">
+            <DatePicker showTime style={{ width: "100%" }} />
+          </Form.Item>
+          <Form.Item label="最低版本(APP)" name="minVersion">
+            <Input placeholder="例如:1.0.0" />
+          </Form.Item>
+          <Form.Item label="最高版本(APP)" name="maxVersion">
+            <Input placeholder="例如:9.9.9" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+};
+
+export default BannerConfigPage;

+ 17 - 0
src/config/menus.tsx

@@ -165,6 +165,23 @@ export const menuConfig: MenuItem[] = [
       },
     ],
   },
+  {
+    key: "operation",
+    label: "运营管理",
+    path: "/operation",
+    icon: <ControlOutlined />,
+    sortOrder: 7,
+    permission: "/operation",
+    children: [
+      {
+        key: "banner-config",
+        label: "Banner配置",
+        path: "/operation/banner-config",
+        icon: <ControlOutlined />,
+        permission: "/operation/banner-config",
+      },
+    ],
+  },
   {
     key: "config",
     label: "基础配置",

+ 51 - 0
src/services/bannerConfig.ts

@@ -0,0 +1,51 @@
+/**
+ * Banner Config (banner-config) API services
+ */
+
+import { post } from "@/lib/request";
+import type {
+  BannerConfigAdminDTO,
+  BannerConfigAdminQuery,
+  IdListReq,
+  IdReq,
+  PagerBannerConfigAdminDTO,
+} from "@/types/api";
+
+/**
+ * Get banner config page data
+ */
+export async function getBannerConfigPage(
+  query: BannerConfigAdminQuery,
+): Promise<PagerBannerConfigAdminDTO> {
+  return post<PagerBannerConfigAdminDTO>("/config/banner-config/page", query);
+}
+
+/**
+ * Get single banner config record
+ */
+export async function getBannerConfig(req: IdReq): Promise<BannerConfigAdminDTO> {
+  return post<BannerConfigAdminDTO>("/config/banner-config/get", req);
+}
+
+/**
+ * Create banner config record
+ */
+export async function createBannerConfig(data: BannerConfigAdminDTO): Promise<void> {
+  return post<void>("/config/banner-config/create", data);
+}
+
+/**
+ * Update banner config record
+ */
+export async function updateBannerConfig(data: BannerConfigAdminDTO): Promise<void> {
+  return post<void>("/config/banner-config/update", data);
+}
+
+/**
+ * Delete banner config records
+ */
+export async function deleteBannerConfigs(req: IdListReq): Promise<void> {
+  return post<void>("/config/banner-config/delete", req);
+}
+
+

+ 75 - 0
src/types/api/bannerConfig.ts

@@ -0,0 +1,75 @@
+/**
+ * Banner config (banner-config) API type definitions
+ */
+
+import type { QuerySort } from "./common";
+
+/**
+ * Banner 广告传输对象
+ */
+export interface BannerConfigAdminDTO {
+  id?: string;
+  createdAt?: number;
+  updatedAt?: number;
+  /** 标题 */
+  title?: string;
+  /** 副标题 */
+  subTitle?: string;
+  /** 广告位图片 */
+  imageUrl?: string;
+  /** 广告位列表 */
+  adSlots?: number[];
+  /** 是否所有广告位有效 */
+  allAdSlotEffective?: boolean;
+  /** 跳转目标, 常量 BannerJumpTargetTypes */
+  jumpTarget?: number;
+  /** 跳转链接 */
+  jumpLink?: string;
+  /** 排序优先级 */
+  priority?: number;
+  /** 生效时间 */
+  startTime?: number;
+  /** 失效时间 */
+  endTime?: number;
+  /** 0/NULL=全部,1=安卓,2=苹果,3=浏览器,4=电脑应用 */
+  platform?: number;
+  /** APP - 最低版本 */
+  minVersion?: string;
+  /** APP - 最高版本 */
+  maxVersion?: string;
+}
+
+/**
+ * Banner 广告配置分页请求
+ */
+export interface BannerConfigAdminQuery {
+  pageSize?: number;
+  pageIndex?: number;
+  sort?: QuerySort;
+  /** 标题 */
+  title?: string;
+  /** 发布平台 - 1:安卓,2:苹果,3:浏览器,4:电脑应用 */
+  platform?: number;
+  skip?: number;
+}
+
+/**
+ * 分页响应
+ */
+export interface PagerBannerConfigAdminDTO {
+  total: number;
+  pageIndex: number;
+  pageSize: number;
+  items: BannerConfigAdminDTO[];
+}
+
+/**
+ * Common ID request wrappers (Swagger sometimes uses ObjectId object)
+ */
+export interface IdReq {
+  id: string;
+}
+
+export interface IdListReq {
+  ids: string[];
+}

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

@@ -92,6 +92,14 @@ export type {
   IndicatorCardAdminDTO,
   RevenueByCategoryAdminDTO,
 } from "./indexStat";
+// Banner config types
+export type {
+  BannerConfigAdminDTO,
+  BannerConfigAdminQuery,
+  IdListReq,
+  IdReq,
+  PagerBannerConfigAdminDTO,
+} from "./bannerConfig";
 // Operation log types
 export type {
   OperationLog,