فهرست منبع

添加配置页面

ChenYu 7 ماه پیش
والد
کامیت
6f93433ad7
7فایلهای تغییر یافته به همراه582 افزوده شده و 0 حذف شده
  1. 162 0
      doc/CONFIG_API.md
  2. 53 0
      src/api/config.ts
  3. 29 0
      src/i18n/locales/en.json
  4. 29 0
      src/i18n/locales/zh.json
  5. 3 0
      src/layouts/AdminLayout.tsx
  6. 304 0
      src/pages/config/ConfigPage.tsx
  7. 2 0
      src/router/index.tsx

+ 162 - 0
doc/CONFIG_API.md

@@ -0,0 +1,162 @@
+# 系统配置 API 文档
+
+本文档描述系统配置 `t_config` 相关接口。
+
+## 1. 基础信息
+
+*   **URL 前缀**: `/v2/config`
+*   **响应格式**: 统一使用 `GlobalResponseWrapper`
+
+---
+
+## 2. 接口列表
+
+### 2.1 获取全部配置
+
+*   **URL**: `/list`
+*   **Method**: `GET`
+*   **描述**: 查询全部系统配置,默认按 `configKey` 升序返回。
+
+#### 请求参数
+
+无
+
+#### 响应参数 (`data` 字段)
+
+返回 `TConfig` 对象列表,元素结构如下:
+
+| 字段名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `configKey` | string | 配置 key,主键 |
+| `configValue` | string | 配置值 |
+| `remark` | string | 说明 |
+| `createTime` | string | 创建时间,格式:`yyyy-MM-dd HH:mm:ss` |
+| `updateTime` | string | 更新时间,格式:`yyyy-MM-dd HH:mm:ss` |
+
+#### 示例响应
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": [
+    {
+      "configKey": "site_name",
+      "configValue": "Aitori BI",
+      "remark": "站点名称",
+      "createTime": "2026-03-25 10:00:00",
+      "updateTime": "2026-03-25 10:00:00"
+    }
+  ]
+}
+```
+
+### 2.2 分页查询配置
+
+*   **URL**: `/page`
+*   **Method**: `GET`
+*   **描述**: 分页查询系统配置,支持按 `configKey`、`configValue`、`remark` 关键字模糊匹配。
+
+#### 请求参数
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `page` | integer | 否 | 页码,默认 `1` |
+| `size` | integer | 否 | 每页大小,默认 `10` |
+| `keyword` | string | 否 | 关键字,支持配置 key / 配置值 / 说明模糊匹配 |
+
+#### 响应参数 (`data` 字段)
+
+| 字段名 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `records` | array | 当前页配置列表 |
+| `total` | integer | 总记录数 |
+| `size` | integer | 每页数量 |
+| `current` | integer | 当前页码 |
+| `pages` | integer | 总页数 |
+
+### 2.3 获取配置详情
+
+*   **URL**: `/get/{configKey}`
+*   **Method**: `GET`
+*   **描述**: 根据配置 key 获取单个系统配置。
+
+#### 请求参数
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `configKey` | string | 是 | 配置 key |
+
+#### 响应参数 (`data` 字段)
+
+返回单个 `TConfig` 对象,字段说明同“获取全部配置”。
+
+### 2.4 新增配置
+
+*   **URL**: `/add`
+*   **Method**: `POST`
+*   **Content-Type**: `application/json`
+*   **描述**: 新增一条系统配置。`configKey` 不能为空,且不能重复。
+
+#### 请求参数 (Body)
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `configKey` | string | 是 | 配置 key,主键 |
+| `configValue` | string | 否 | 配置值 |
+| `remark` | string | 否 | 说明 |
+
+#### 请求示例
+
+```json
+{
+  "configKey": "site_name",
+  "configValue": "Aitori BI",
+  "remark": "站点名称"
+}
+```
+
+#### 响应参数
+
+| 类型 | 说明 |
+| :--- | :--- |
+| boolean | 操作结果 (`true/false`) |
+
+### 2.5 更新配置
+
+*   **URL**: `/update`
+*   **Method**: `PUT`
+*   **Content-Type**: `application/json`
+*   **描述**: 更新已存在的系统配置。`configKey` 不能为空,且必须已存在。
+
+#### 请求参数 (Body)
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `configKey` | string | 是 | 配置 key,主键 |
+| `configValue` | string | 否 | 配置值 |
+| `remark` | string | 否 | 说明 |
+
+#### 响应参数
+
+| 类型 | 说明 |
+| :--- | :--- |
+| boolean | 操作结果 (`true/false`) |
+
+### 2.6 删除配置
+
+*   **URL**: `/delete/{configKey}`
+*   **Method**: `DELETE`
+*   **描述**: 根据配置 key 删除系统配置。
+
+#### 请求参数
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `configKey` | string | 是 | 配置 key |
+
+#### 响应参数
+
+| 类型 | 说明 |
+| :--- | :--- |
+| boolean | 操作结果 (`true/false`) |

+ 53 - 0
src/api/config.ts

@@ -0,0 +1,53 @@
+import { get, post, put, del } from "@/utils/request";
+
+export interface ConfigItem {
+  configKey: string;
+  configValue?: string;
+  remark?: string;
+  createTime?: string;
+  updateTime?: string;
+}
+
+export interface ConfigPageParams {
+  page?: number;
+  size?: number;
+  keyword?: string;
+}
+
+export interface PageResult<T> {
+  records: T[];
+  total: number;
+  size: number;
+  current: number;
+  pages: number;
+}
+
+export type ConfigForm = Pick<ConfigItem, "configKey" | "configValue" | "remark">;
+
+export function getConfigList() {
+  return get<ConfigItem[]>("/v2/config/list");
+}
+
+export function getConfigPage(params: ConfigPageParams) {
+  const query: Record<string, string> = {};
+  if (params.page != null) query.page = String(params.page);
+  if (params.size != null) query.size = String(params.size);
+  if (params.keyword) query.keyword = params.keyword;
+  return get<PageResult<ConfigItem>>("/v2/config/page", query);
+}
+
+export function getConfig(configKey: string) {
+  return get<ConfigItem>(`/v2/config/get/${encodeURIComponent(configKey)}`);
+}
+
+export function addConfig(data: ConfigForm) {
+  return post<boolean>("/v2/config/add", data);
+}
+
+export function updateConfig(data: ConfigForm) {
+  return put<boolean>("/v2/config/update", data);
+}
+
+export function deleteConfig(configKey: string) {
+  return del<boolean>(`/v2/config/delete/${encodeURIComponent(configKey)}`);
+}

+ 29 - 0
src/i18n/locales/en.json

@@ -62,6 +62,7 @@
     "aiAlbum": "AI Albums",
     "subscribeAction": "Subscribe Ads",
     "aiSubscribe": "AI Subscription",
+    "config": "System Config",
     "groupHomepage": "Homepage",
     "groupUser": "Users",
     "groupPoints": "Points",
@@ -596,6 +597,34 @@
     "daysUnit": "{{count}} days",
     "unknownCode": "Unknown"
   },
+  "config": {
+    "title": "System Config",
+    "addBtn": "Add Config",
+    "addTitle": "Add System Config",
+    "editTitle": "Edit System Config",
+    "refresh": "Refresh",
+    "reset": "Reset",
+    "required": "This field is required",
+    "fetchFailed": "Failed to load config details",
+    "addSuccess": "Config created successfully",
+    "updateSuccess": "Config updated successfully",
+    "deleteSuccess": "Config deleted successfully",
+    "deleteConfirm": "Are you sure you want to delete this config?",
+    "searchPlaceholder": "Search key / value / remark",
+    "searchTip": "Supports fuzzy matching on config key, value, or remark",
+    "colKey": "Config Key",
+    "colValue": "Config Value",
+    "colRemark": "Remark",
+    "colCreateTime": "Created",
+    "colUpdateTime": "Updated",
+    "colActions": "Actions",
+    "fieldKey": "Config Key",
+    "fieldValue": "Config Value",
+    "fieldRemark": "Remark",
+    "keyPlaceholder": "Enter a unique config key, e.g. site_name",
+    "valuePlaceholder": "Enter config value",
+    "remarkPlaceholder": "Enter remark"
+  },
   "pointsProduct": {
     "title": "Points Products",
     "addBtn": "Add Product",

+ 29 - 0
src/i18n/locales/zh.json

@@ -62,6 +62,7 @@
     "aiAlbum": "相册管理",
     "subscribeAction": "订阅广告配置",
     "aiSubscribe": "AI 订阅配置",
+    "config": "系统配置",
     "groupHomepage": "首页管理",
     "groupUser": "用户中心",
     "groupPoints": "积分体系",
@@ -596,6 +597,34 @@
     "daysUnit": "{{count}} 天",
     "unknownCode": "未知"
   },
+  "config": {
+    "title": "系统配置",
+    "addBtn": "新增配置",
+    "addTitle": "新增系统配置",
+    "editTitle": "编辑系统配置",
+    "refresh": "刷新",
+    "reset": "重置",
+    "required": "此项为必填项",
+    "fetchFailed": "获取配置详情失败",
+    "addSuccess": "配置新增成功",
+    "updateSuccess": "配置更新成功",
+    "deleteSuccess": "配置删除成功",
+    "deleteConfirm": "确定要删除此配置吗?",
+    "searchPlaceholder": "搜索配置 key / 值 / 说明",
+    "searchTip": "支持按配置 key、配置值或说明关键字模糊匹配",
+    "colKey": "配置 Key",
+    "colValue": "配置值",
+    "colRemark": "说明",
+    "colCreateTime": "创建时间",
+    "colUpdateTime": "更新时间",
+    "colActions": "操作",
+    "fieldKey": "配置 Key",
+    "fieldValue": "配置值",
+    "fieldRemark": "说明",
+    "keyPlaceholder": "请输入唯一的配置 Key,如 site_name",
+    "valuePlaceholder": "请输入配置值",
+    "remarkPlaceholder": "请输入配置说明"
+  },
   "pointsProduct": {
     "title": "积分产品管理",
     "addBtn": "新增产品",

+ 3 - 0
src/layouts/AdminLayout.tsx

@@ -41,6 +41,7 @@ import {
   NotificationOutlined,
   ReconciliationOutlined,
   PictureOutlined,
+  SettingOutlined,
 } from "@ant-design/icons";
 import { useAuthStore } from "@/stores/useAuthStore";
 
@@ -74,6 +75,7 @@ const MENU_ITEMS: MenuProps["items"] = [
     { key: "/ai-album", icon: <PictureOutlined />, label: "" },
     { key: "/subscribe-action", icon: <NotificationOutlined />, label: "" },
     { key: "/ai-subscribe", icon: <CrownOutlined />, label: "" },
+    { key: "/config", icon: <SettingOutlined />, label: "" },
   ]},
 ];
 
@@ -106,6 +108,7 @@ export function AdminLayout() {
       "/ai-album": t("sidebar.aiAlbum"),
       "/subscribe-action": t("sidebar.subscribeAction"),
       "/ai-subscribe": t("sidebar.aiSubscribe"),
+      "/config": t("sidebar.config"),
     };
     const GROUP_LABEL_MAP: Record<string, string> = {
       "menu-group-homepage": t("sidebar.groupHomepage"),

+ 304 - 0
src/pages/config/ConfigPage.tsx

@@ -0,0 +1,304 @@
+import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+  Typography,
+  Button,
+  Table,
+  Popconfirm,
+  message,
+  Card,
+  Modal,
+  Form,
+  Input,
+  Space,
+} from "antd";
+import type { TableProps } from "antd";
+import {
+  PlusOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  ReloadOutlined,
+  SearchOutlined,
+} from "@ant-design/icons";
+import type { ConfigForm, ConfigItem } from "@/api/config";
+import {
+  addConfig,
+  deleteConfig,
+  getConfig,
+  getConfigPage,
+  updateConfig,
+} from "@/api/config";
+
+const { Title, Text } = Typography;
+const { TextArea } = Input;
+
+export function ConfigPage() {
+  const { t } = useTranslation();
+  const [data, setData] = useState<ConfigItem[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [modalOpen, setModalOpen] = useState(false);
+  const [editingKey, setEditingKey] = useState<string | null>(null);
+  const [total, setTotal] = useState(0);
+  const [keyword, setKeyword] = useState("");
+  const [query, setQuery] = useState({ page: 1, size: 10, keyword: undefined as string | undefined });
+  const [form] = Form.useForm<ConfigForm>();
+
+  const fetchData = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await getConfigPage(query);
+      setData(res.records);
+      setTotal(res.total);
+    } catch {
+      // handled by request util
+    } finally {
+      setLoading(false);
+    }
+  }, [query]);
+
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
+
+  function handleSearch() {
+    setQuery((prev) => ({
+      ...prev,
+      page: 1,
+      keyword: keyword.trim() || undefined,
+    }));
+  }
+
+  function handleReset() {
+    setKeyword("");
+    setQuery({ page: 1, size: 10, keyword: undefined });
+  }
+
+  function openAdd() {
+    setEditingKey(null);
+    form.resetFields();
+    setModalOpen(true);
+  }
+
+  async function openEdit(record: ConfigItem) {
+    setEditingKey(record.configKey);
+    setModalOpen(true);
+    try {
+      const detail = await getConfig(record.configKey);
+      form.setFieldsValue({
+        configKey: detail.configKey,
+        configValue: detail.configValue,
+        remark: detail.remark,
+      });
+    } catch {
+      setModalOpen(false);
+      setEditingKey(null);
+      message.error(t("config.fetchFailed"));
+    }
+  }
+
+  async function handleSubmit() {
+    try {
+      const values = await form.validateFields();
+      setSubmitting(true);
+      if (editingKey) {
+        await updateConfig(values);
+        message.success(t("config.updateSuccess"));
+      } else {
+        await addConfig(values);
+        message.success(t("config.addSuccess"));
+      }
+      setModalOpen(false);
+      setEditingKey(null);
+      form.resetFields();
+      fetchData();
+    } catch {
+      // validation or request error
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  async function handleDelete(configKey: string) {
+    try {
+      await deleteConfig(configKey);
+      message.success(t("config.deleteSuccess"));
+      fetchData();
+    } catch {
+      // handled by request util
+    }
+  }
+
+  const columns: TableProps<ConfigItem>["columns"] = [
+    {
+      title: t("config.colKey"),
+      dataIndex: "configKey",
+      width: 220,
+      fixed: "left",
+      render: (val: string) => <span className="font-mono text-xs">{val}</span>,
+    },
+    {
+      title: t("config.colValue"),
+      dataIndex: "configValue",
+      width: 320,
+      render: (val?: string) => (
+        <div
+          className="text-sm"
+          style={{
+            lineHeight: "20px",
+            whiteSpace: "pre-wrap",
+            wordBreak: "break-word",
+          }}
+        >
+          {val || "—"}
+        </div>
+      ),
+    },
+    {
+      title: t("config.colRemark"),
+      dataIndex: "remark",
+      width: 240,
+      render: (val?: string) => <span>{val || "—"}</span>,
+    },
+    {
+      title: t("config.colCreateTime"),
+      dataIndex: "createTime",
+      width: 170,
+      render: (val?: string) => val || "—",
+    },
+    {
+      title: t("config.colUpdateTime"),
+      dataIndex: "updateTime",
+      width: 170,
+      render: (val?: string) => val || "—",
+    },
+    {
+      title: t("config.colActions"),
+      key: "actions",
+      width: 140,
+      fixed: "right",
+      align: "center",
+      render: (_, record) => (
+        <Space size={0}>
+          <Button
+            type="link"
+            size="small"
+            icon={<EditOutlined />}
+            onClick={() => openEdit(record)}
+          >
+            {t("common.edit")}
+          </Button>
+          <Popconfirm
+            title={t("config.deleteConfirm")}
+            onConfirm={() => handleDelete(record.configKey)}
+            okText={t("common.confirm")}
+            cancelText={t("common.cancel")}
+          >
+            <Button type="link" size="small" danger icon={<DeleteOutlined />}>
+              {t("common.delete")}
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-4">
+        <Title level={4} style={{ margin: 0 }}>
+          {t("config.title")}
+        </Title>
+        <Space>
+          <Button icon={<ReloadOutlined />} onClick={fetchData}>
+            {t("config.refresh")}
+          </Button>
+          <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
+            {t("config.addBtn")}
+          </Button>
+        </Space>
+      </div>
+
+      <Card style={{ marginBottom: 16 }}>
+        <div className="flex flex-wrap items-center gap-3">
+          <Input
+            placeholder={t("config.searchPlaceholder")}
+            prefix={<SearchOutlined />}
+            value={keyword}
+            onChange={(e) => setKeyword(e.target.value)}
+            onPressEnter={handleSearch}
+            style={{ width: 320 }}
+            allowClear
+          />
+          <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
+            {t("common.search")}
+          </Button>
+          <Button icon={<ReloadOutlined />} onClick={handleReset}>
+            {t("config.reset")}
+          </Button>
+        </div>
+        <Text type="secondary" style={{ display: "block", marginTop: 12, fontSize: 12 }}>
+          {t("config.searchTip")}
+        </Text>
+      </Card>
+
+      <Card>
+        <Table<ConfigItem>
+          rowKey="configKey"
+          columns={columns}
+          dataSource={data}
+          loading={loading}
+          scroll={{ x: 1200 }}
+          size="middle"
+          pagination={{
+            current: query.page,
+            pageSize: query.size,
+            total,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            showTotal: (count) => t("common.paginationTotal", { total: count }),
+            onChange: (page, size) => setQuery((prev) => ({ ...prev, page, size })),
+          }}
+        />
+      </Card>
+
+      <Modal
+        open={modalOpen}
+        title={editingKey ? t("config.editTitle") : t("config.addTitle")}
+        onCancel={() => {
+          setModalOpen(false);
+          setEditingKey(null);
+          form.resetFields();
+        }}
+        onOk={handleSubmit}
+        confirmLoading={submitting}
+        destroyOnClose
+      >
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item
+            name="configKey"
+            label={t("config.fieldKey")}
+            rules={[{ required: true, message: t("config.required") }]}
+          >
+            <Input
+              placeholder={t("config.keyPlaceholder")}
+              disabled={!!editingKey}
+            />
+          </Form.Item>
+          <Form.Item name="configValue" label={t("config.fieldValue")}>
+            <TextArea
+              rows={4}
+              placeholder={t("config.valuePlaceholder")}
+            />
+          </Form.Item>
+          <Form.Item name="remark" label={t("config.fieldRemark")}>
+            <TextArea
+              rows={3}
+              placeholder={t("config.remarkPlaceholder")}
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  );
+}

+ 2 - 0
src/router/index.tsx

@@ -26,6 +26,7 @@ import { SubscribeActionPage } from "@/pages/subscribe-action/SubscribeActionPag
 import { SubscribeActionFormPage } from "@/pages/subscribe-action/SubscribeActionFormPage";
 import { AiOrderV2Page } from "@/pages/ai-order-v2/AiOrderV2Page";
 import { AiAlbumPage } from "@/pages/ai-album/AiAlbumPage";
+import { ConfigPage } from "@/pages/config/ConfigPage";
 
 export const router = createBrowserRouter([
   {
@@ -69,6 +70,7 @@ export const router = createBrowserRouter([
       { path: "subscribe-action", element: <SubscribeActionPage /> },
       { path: "subscribe-action/add", element: <SubscribeActionFormPage /> },
       { path: "subscribe-action/edit/:id", element: <SubscribeActionFormPage /> },
+      { path: "config", element: <ConfigPage /> },
     ],
   },
   {