This is an automated email from the ASF dual-hosted git repository.

liuhongyu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new f5a54b67 feat:ai proxy apikey (#548)
f5a54b67 is described below

commit f5a54b67ed953a74afb1a5ab5b9f6c4f1bdb55ac
Author: LingXiao Qi <[email protected]>
AuthorDate: Thu Sep 25 18:13:37 2025 +0800

    feat:ai proxy apikey (#548)
    
    * feat:ai proxy apikey
    
    * add use info
    
    * fix comment and clear selector state when delete
---
 src/common/router.js                       |   7 +
 src/locales/en-US.json                     |  12 +-
 src/locales/zh-CN.json                     |  12 +-
 src/models/pluginHandle.js                 |  38 +-
 src/routes/Plugin/AiProxy/ApiKeys/index.js | 570 +++++++++++++++++++++++++++++
 src/routes/Plugin/Common/index.js          | 388 ++++++++++++--------
 src/services/api.js                        |  66 ++++
 7 files changed, 934 insertions(+), 159 deletions(-)

diff --git a/src/common/router.js b/src/common/router.js
index 66b454e7..fefb2499 100644
--- a/src/common/router.js
+++ b/src/common/router.js
@@ -119,6 +119,13 @@ export const getRouterData = (app) => {
         () => import("../routes/Plugin/Common"),
       ),
     },
+    "/plugin/ai-proxy/apikeys": {
+      component: dynamicWrapper(
+        app,
+        [],
+        () => import("../routes/Plugin/AiProxy/ApiKeys"),
+      ),
+    },
     "/system/role": {
       component: dynamicWrapper(
         app,
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index 8ccf3578..9d17af2a 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -635,5 +635,15 @@
   "SHENYU.MCP.CONFIG.EXPLANATION.HEADERS": "HTTP headers required for 
requests. X-Client-ID and Authorization should be filled according to actual 
situation",
   "SHENYU.MCP.CONFIG.EXPLANATION.TRANSPORT": "Transport protocol type",
   "SHENYU.MCP.CONFIG.JSON.TITLE": "JSON Configuration (Copy Ready):",
-  "SHENYU.MCP.CONFIG.COPY.JSON": "Copy JSON"
+  "SHENYU.MCP.CONFIG.COPY.JSON": "Copy JSON",
+  "APIPROXY.APIKEY.MANAGE": "API Key Manage",
+  "APIPROXY.APIKEY.CREATE": "Create API Key",
+  "APIPROXY.APIKEY.EDIT": "Edit API Key",
+  "APIPROXY.APIKEY.ONLY_ONE_SELECTOR": "Please select only one selector",
+  "APIPROXY.APIKEY.USAGE.TITLE": "Usage",
+  "APIPROXY.APIKEY.USAGE.DESC": "Proxy API Key is bound at selector level. 
When using, add header X-API-KEY: <proxyApiKey>. This check has the highest 
priority. The realApiKey is strongly bound to the selector configuration. To 
change the mapping between proxyApiKey and realApiKey, just modify the selector 
configuration.",
+  "APIPROXY.APIKEY.USAGE.LINE1": "Proxy API Key works at selector granularity. 
Add header X-API-KEY: <proxyApiKey>; this check has the highest priority.",
+  "APIPROXY.APIKEY.USAGE.LINE2": "The realApiKey is strongly bound to the 
selector configuration. To change the mapping between proxyApiKey and 
realApiKey, edit the selector configuration.",
+  "APIPROXY.APIKEY.USAGE.HINT": "How to use: Add this API Key to the X-API-KEY 
field in your request header.",
+  "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED": "Please set proxyEnabled to 
\"true\" in this selector's configuration before entering Proxy API Key 
Management."
 }
diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json
index 221ff74c..c6f3510a 100644
--- a/src/locales/zh-CN.json
+++ b/src/locales/zh-CN.json
@@ -641,5 +641,15 @@
   "SHENYU.MCP.CONFIG.EXPLANATION.HEADERS": 
"请求时需要携带的HTTP头信息。X-Client-ID和Authorization需要根据实际情况填写",
   "SHENYU.MCP.CONFIG.EXPLANATION.TRANSPORT": "传输协议类型",
   "SHENYU.MCP.CONFIG.JSON.TITLE": "JSON配置(可直接复制):",
-  "SHENYU.MCP.CONFIG.COPY.JSON": "复制JSON"
+  "SHENYU.MCP.CONFIG.COPY.JSON": "复制JSON",
+  "APIPROXY.APIKEY.MANAGE": "API Key 管理",
+  "APIPROXY.APIKEY.CREATE": "新增 API Key",
+  "APIPROXY.APIKEY.EDIT": "编辑 API Key",
+  "APIPROXY.APIKEY.ONLY_ONE_SELECTOR": "只能选择一个选择器",
+  "APIPROXY.APIKEY.USAGE.TITLE": "使用提示",
+  "APIPROXY.APIKEY.USAGE.DESC": "代理 API Key 绑定在选择器(Selector)维度。调用时在请求头添加 
X-API-KEY: <proxyApiKey>,该校验优先级最高。真实 API Key 强绑定于选择器(Selector)配置,如需改变代理 API Key 
与真实 API Key 的映射,只需修改选择器配置即可。",
+  "APIPROXY.APIKEY.USAGE.LINE1": "代理 API Key 以选择器(Selector)为粒度生效,请在请求头添加 
X-API-KEY: <proxyApiKey>,其校验优先级最高。",
+  "APIPROXY.APIKEY.USAGE.LINE2": "真实 API Key 
与选择器配置强绑定,如需变更代理与真实的映射,请修改该选择器的配置。",
+  "APIPROXY.APIKEY.USAGE.HINT": "使用提示:请将此 API Key 添加到请求头的 X-API-KEY 字段。",
+  "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED": "请先在该选择器的配置中将 proxyEnabled 设置为 
\"true\",再进入代理 API Key 管理。"
 }
diff --git a/src/models/pluginHandle.js b/src/models/pluginHandle.js
index d587a9f5..9a912bb6 100644
--- a/src/models/pluginHandle.js
+++ b/src/models/pluginHandle.js
@@ -114,7 +114,13 @@ export default {
         typeof handle !== "undefined" &&
         handle.indexOf("{") !== -1
       ) {
-        handleJson = JSON.parse(handle);
+        try {
+          handleJson = JSON.parse(handle);
+        } catch (e) {
+          // eslint-disable-next-line no-console
+          console.error("Failed to parse plugin handle JSON:", handle, e);
+          handleJson = undefined;
+        }
       }
       const json = yield call(fetchPluginHandleByPluginId, payload);
       if (json.code === 200) {
@@ -148,17 +154,29 @@ export default {
                 typeof item.extObj !== "undefined" &&
                 item.extObj.indexOf("{") !== -1
               ) {
-                let extObj = JSON.parse(item.extObj);
-                item.required = extObj.required;
-                if (extObj.defaultValue || extObj.defaultValue === 0) {
-                  if (item.dataType === 1) {
-                    item.defaultValue = Number(extObj.defaultValue);
-                  } else {
-                    item.defaultValue = extObj.defaultValue;
+                try {
+                  let extObj = JSON.parse(item.extObj);
+                  item.required = extObj && extObj.required;
+                  if (
+                    extObj &&
+                    (extObj.defaultValue || extObj.defaultValue === 0)
+                  ) {
+                    if (item.dataType === 1) {
+                      item.defaultValue = Number(extObj.defaultValue);
+                    } else {
+                      item.defaultValue = extObj.defaultValue;
+                    }
                   }
+                  item.checkRule = extObj && extObj.rule;
+                  item.placeholder = extObj && extObj.placeholder;
+                } catch (e) {
+                  // eslint-disable-next-line no-console
+                  console.error(
+                    "Failed to parse extObj JSON:",
+                    item && item.extObj,
+                    e,
+                  );
                 }
-                item.checkRule = extObj.rule;
-                item.placeholder = extObj.placeholder;
               }
               return item;
             });
diff --git a/src/routes/Plugin/AiProxy/ApiKeys/index.js 
b/src/routes/Plugin/AiProxy/ApiKeys/index.js
new file mode 100644
index 00000000..d8812cca
--- /dev/null
+++ b/src/routes/Plugin/AiProxy/ApiKeys/index.js
@@ -0,0 +1,570 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { useEffect, useMemo, useState } from "react";
+import { connect } from "dva";
+import {
+  Button,
+  Input,
+  Row,
+  Col,
+  Table,
+  Switch,
+  Modal,
+  Form,
+  Typography,
+  Popconfirm,
+  message,
+  Alert,
+} from "antd";
+import styles from "../../index.less";
+import { getIntlContent } from "../../../../utils/IntlUtils";
+import AuthButton from "../../../../utils/AuthButton";
+import {
+  addAiProxyApiKey,
+  getAiProxyApiKeys,
+  updateAiProxyApiKey,
+  batchDeleteAiProxyApiKeys,
+  batchEnableAiProxyApiKeys,
+} from "../../../../services/api";
+
+const { Search } = Input;
+const { Title } = Typography;
+
+// proxyApiKey needs to be fully visible on the management page, so we do not 
mask it
+class CreateModalInner extends React.Component {
+  componentDidUpdate(prevProps) {
+    const { visible, defaultNamespace, form } = this.props;
+    if (visible && visible !== prevProps.visible) {
+      form.resetFields();
+      form.setFieldsValue({
+        enabled: true,
+        namespaceId: defaultNamespace,
+      });
+    }
+  }
+
+  handleOk = () => {
+    const { form, onOk } = this.props;
+    form.validateFieldsAndScroll((err, values) => {
+      if (!err) {
+        onOk({ ...values });
+      }
+    });
+  };
+
+  render() {
+    const { visible, onCancel, defaultNamespace, form, realApiKeyPreset } =
+      this.props;
+    const { getFieldDecorator } = form;
+    return (
+      <Modal
+        title={getIntlContent("APIPROXY.APIKEY.CREATE") || "Create API Key"}
+        visible={visible}
+        onCancel={onCancel}
+        onOk={this.handleOk}
+        okText={getIntlContent("SHENYU.COMMON.SAVE")}
+        cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+        destroyOnClose
+      >
+        <Form layout="vertical">
+          <Form.Item label="Real API Key">
+            <Input value={realApiKeyPreset} disabled />
+          </Form.Item>
+          <Form.Item
+            label={getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description"}
+          >
+            {getFieldDecorator("description")(<Input maxLength={200} />)}
+          </Form.Item>
+          <Form.Item label={getIntlContent("SHENYU.COMMON.OPEN") || "Enabled"}>
+            {getFieldDecorator("enabled", {
+              valuePropName: "checked",
+              initialValue: true,
+            })(<Switch />)}
+          </Form.Item>
+          <Form.Item label="Namespace">
+            {getFieldDecorator("namespaceId", {
+              initialValue: defaultNamespace,
+            })(<Input disabled placeholder="default" />)}
+          </Form.Item>
+        </Form>
+      </Modal>
+    );
+  }
+}
+const CreateModal = Form.create()(CreateModalInner);
+
+function EditModalStateless(props) {
+  const { visible, onCancel, onOk, record, defaultNamespace, form } = props;
+  const { getFieldDecorator, resetFields, setFieldsValue } = form;
+  React.useEffect(() => {
+    if (visible) {
+      resetFields();
+      setFieldsValue({
+        description: record && record.description,
+        enabled: record && record.enabled,
+        namespaceId: defaultNamespace,
+      });
+    }
+  }, [visible, record, defaultNamespace]);
+
+  const handleOk = () => {
+    form.validateFieldsAndScroll((err, values) => {
+      if (!err) onOk(values);
+    });
+  };
+
+  return (
+    <Modal
+      title={getIntlContent("APIPROXY.APIKEY.EDIT") || "Edit API Key"}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={handleOk}
+      okText={getIntlContent("SHENYU.COMMON.SAVE")}
+      cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+      destroyOnClose
+    >
+      <Form layout="vertical">
+        <Form.Item label="Proxy API Key">
+          <Input value={record && record.proxyApiKey} disabled />
+        </Form.Item>
+        <Form.Item
+          label={getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description"}
+        >
+          {getFieldDecorator("description", {
+            initialValue: record && record.description,
+          })(<Input maxLength={200} />)}
+        </Form.Item>
+        <Form.Item label={getIntlContent("SHENYU.COMMON.OPEN") || "Enabled"}>
+          {getFieldDecorator("enabled", {
+            valuePropName: "checked",
+            initialValue: record ? record.enabled : true,
+          })(<Switch />)}
+        </Form.Item>
+        <Form.Item label="Namespace">
+          {getFieldDecorator("namespaceId", {
+            initialValue: defaultNamespace,
+          })(<Input disabled placeholder="default" />)}
+        </Form.Item>
+      </Form>
+    </Modal>
+  );
+}
+const EditModal = Form.create()(EditModalStateless);
+
+function ApiKeysPage({
+  currentNamespaceId,
+  initialSelectorId,
+  initialNamespaceId,
+  onBack,
+  currentSelector,
+}) {
+  const [selectorId, setSelectorId] = useState(initialSelectorId);
+  const [namespaceId, setNamespaceId] = useState(
+    initialNamespaceId || currentNamespaceId || "default",
+  );
+  const [queryKey, setQueryKey] = useState();
+  const [data, setData] = useState([]);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+  const [total, setTotal] = useState(0);
+  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+  const [createOpen, setCreateOpen] = useState(false);
+  const [editOpen, setEditOpen] = useState(false);
+  const [current, setCurrent] = useState();
+  const realKeyFromSelector = useMemo(() => {
+    try {
+      const handle = currentSelector && currentSelector.handle;
+      if (handle) {
+        const obj = typeof handle === "string" ? JSON.parse(handle) : handle;
+        return obj && (obj.realApiKey || obj.realKey || obj.apiKey);
+      }
+    } catch (e) {
+      // ignore
+    }
+    return undefined;
+  }, [currentSelector]);
+
+  useEffect(() => {
+    if (initialSelectorId) setSelectorId(initialSelectorId);
+    if (initialNamespaceId) setNamespaceId(initialNamespaceId);
+    // eslint-disable-next-line
+  }, [initialSelectorId, initialNamespaceId]);
+
+  useEffect(() => {
+    if (selectorId) {
+      fetchList(1, pageSize);
+    }
+    // eslint-disable-next-line
+  }, [selectorId, namespaceId]);
+
+  const columns = useMemo(
+    () => [
+      { title: "proxyApiKey", dataIndex: "proxyApiKey", key: "proxyApiKey" },
+      {
+        title: getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description",
+        dataIndex: "description",
+        key: "description",
+      },
+      {
+        title: getIntlContent("SHENYU.COMMON.OPEN") || "Enabled",
+        dataIndex: "enabled",
+        key: "enabled",
+        render: (v, r) => (
+          <Switch
+            checked={v}
+            onChange={(checked) => onToggle([r.id], checked)}
+          />
+        ),
+      },
+      {
+        title: getIntlContent("SHENYU.SYSTEM.UPDATETIME") || "Update Time",
+        dataIndex: "dateUpdated",
+        key: "dateUpdated",
+      },
+      {
+        title: getIntlContent("SHENYU.COMMON.OPERAT") || "Operation",
+        key: "op",
+        render: (_, r) => (
+          <div>
+            <AuthButton perms="system:aiProxyApiKey:edit">
+              <span
+                className="edit"
+                style={{ marginRight: 8 }}
+                onClick={() => {
+                  setCurrent(r);
+                  setEditOpen(true);
+                }}
+              >
+                {getIntlContent("SHENYU.COMMON.CHANGE")}
+              </span>
+            </AuthButton>
+            <AuthButton perms="system:aiProxyApiKey:delete">
+              <Popconfirm
+                title={
+                  getIntlContent("SHENYU.COMMON.DELETE") || "Confirm delete?"
+                }
+                onConfirm={() => onBatchDelete([r.id])}
+                okText={getIntlContent("SHENYU.COMMON.SURE")}
+                cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+              >
+                <span className="edit">
+                  {getIntlContent("SHENYU.COMMON.DELETE.NAME")}
+                </span>
+              </Popconfirm>
+            </AuthButton>
+          </div>
+        ),
+      },
+    ],
+    [namespaceId, onToggle],
+  );
+
+  const fetchList = async (p = page, ps = pageSize) => {
+    if (!selectorId) return;
+    const res = await getAiProxyApiKeys({
+      selectorId,
+      namespaceId,
+      currentPage: p,
+      pageSize: ps,
+      proxyApiKey:
+        queryKey && queryKey.trim() !== "" ? queryKey.trim() : undefined,
+    });
+    const list = res?.data?.dataList || [];
+    setData(list);
+    setTotal(res?.data?.totalCount || 0);
+  };
+
+  const onToggle = async (ids, enabled) => {
+    if (!selectorId) return;
+    await batchEnableAiProxyApiKeys({ selectorId, ids, enabled });
+    fetchList();
+  };
+
+  const onBatchDelete = async (ids) => {
+    if (!selectorId) return;
+    await batchDeleteAiProxyApiKeys({ selectorId, ids });
+    fetchList();
+  };
+
+  const onCreate = async (values) => {
+    if (!selectorId) return;
+    const res = await addAiProxyApiKey({ selectorId, ...values });
+    setCreateOpen(false);
+    const newKey = res && res.data && res.data.proxyApiKey;
+    if (newKey) {
+      const copy = async (text) => {
+        try {
+          if (
+            navigator &&
+            navigator.clipboard &&
+            navigator.clipboard.writeText
+          ) {
+            await navigator.clipboard.writeText(text);
+          } else {
+            const ta = document.createElement("textarea");
+            ta.value = text;
+            document.body.appendChild(ta);
+            ta.select();
+            document.execCommand("copy");
+            document.body.removeChild(ta);
+          }
+          message.success(getIntlContent("SHENYU.COMMON.COPY") || "Copy");
+        } catch (e) {
+          message.warn("Copy failed");
+        }
+      };
+      Modal.success({
+        title: getIntlContent("APIPROXY.APIKEY.CREATE") || "Create API Key",
+        content: (
+          <div>
+            <div style={{ marginBottom: 6 }}>Proxy API Key:</div>
+            <div
+              style={{
+                wordBreak: "break-all",
+                fontWeight: 600,
+                marginBottom: 8,
+              }}
+            >
+              {newKey}
+            </div>
+            <Button onClick={() => copy(newKey)}>
+              {getIntlContent("SHENYU.COMMON.COPY") || "Copy"}
+            </Button>
+            <div style={{ marginTop: 16, color: "rgba(0, 0, 0, 0.45)" }}>
+              {getIntlContent("APIPROXY.APIKEY.USAGE.HINT")}
+            </div>
+          </div>
+        ),
+      });
+    } else {
+      message.success(
+        getIntlContent("SHENYU.COMMON.RESPONSE.ADD.SUCCESS") ||
+          "Create success",
+      );
+    }
+    fetchList(1, pageSize);
+  };
+
+  const onEdit = async (values) => {
+    if (!selectorId || !current) return;
+    await updateAiProxyApiKey({ selectorId, id: current.id, ...values });
+    setEditOpen(false);
+    fetchList(page, pageSize);
+  };
+
+  const onBatchDeleteConfirm = () => {
+    if (!selectedRowKeys || selectedRowKeys.length === 0) {
+      message.destroy();
+      message.warn(
+        getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+          "Please select data",
+      );
+      return;
+    }
+    Modal.confirm({
+      title: getIntlContent("SHENYU.COMMON.DELETE") || "Confirm delete?",
+      okText: getIntlContent("SHENYU.COMMON.SURE"),
+      cancelText: getIntlContent("SHENYU.COMMON.CALCEL"),
+      onOk: () => onBatchDelete(selectedRowKeys),
+    });
+  };
+
+  const onBatchEnable = () => {
+    if (!selectedRowKeys || selectedRowKeys.length === 0) {
+      message.destroy();
+      message.warn(
+        getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+          "Please select data",
+      );
+      return;
+    }
+    onToggle(selectedRowKeys, true);
+  };
+
+  const onBatchDisable = () => {
+    if (!selectedRowKeys || selectedRowKeys.length === 0) {
+      message.destroy();
+      message.warn(
+        getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+          "Please select data",
+      );
+      return;
+    }
+    onToggle(selectedRowKeys, false);
+  };
+
+  return (
+    <div className="plug-content-wrap">
+      <Row
+        style={{
+          marginBottom: 5,
+          display: "flex",
+          justifyContent: "space-between",
+          alignItems: "center",
+        }}
+      >
+        <div style={{ display: "flex", alignItems: "end", flex: 1, margin: 0 
}}>
+          {onBack ? (
+            <Button style={{ marginRight: 12 }} onClick={() => onBack()}>
+              {getIntlContent("SHENYU.COMMON.BACK") || "Back"}
+            </Button>
+          ) : null}
+          <Title
+            level={2}
+            style={{ textTransform: "capitalize", margin: "0 20px 0 0" }}
+          >
+            API Key
+          </Title>
+          <Title level={3} type="secondary" style={{ margin: "0 20px 0 0" }}>
+            Ai
+          </Title>
+        </div>
+      </Row>
+
+      {selectorId ? (
+        <Alert
+          type="info"
+          showIcon
+          message={getIntlContent("APIPROXY.APIKEY.USAGE.TITLE")}
+          description={
+            <div>
+              <div>{getIntlContent("APIPROXY.APIKEY.USAGE.LINE1")}</div>
+              <div style={{ marginTop: 4 }}>
+                {getIntlContent("APIPROXY.APIKEY.USAGE.LINE2")}
+              </div>
+            </div>
+          }
+          style={{ marginBottom: 12 }}
+        />
+      ) : null}
+
+      <Row gutter={20}>
+        <Col span={24}>
+          <div className="table-header">
+            <div className={styles.headerSearch}>
+              <Search
+                className={styles.search}
+                style={{ minWidth: 160 }}
+                placeholder="proxyApiKey"
+                onSearch={(v) => {
+                  const value = (v || "").trim();
+                  setQueryKey(value === "" ? undefined : value);
+                  setPage(1);
+                  fetchList(1, pageSize);
+                }}
+              />
+              <Button
+                style={{ marginLeft: 10 }}
+                onClick={() => {
+                  setQueryKey(undefined);
+                  setPage(1);
+                  fetchList(1, pageSize);
+                }}
+              >
+                {getIntlContent("SHENYU.COMMON.REFRESH") || "Refresh"}
+              </Button>
+              <AuthButton perms="system:aiProxyApiKey:add">
+                <Button
+                  type="primary"
+                  onClick={() => setCreateOpen(true)}
+                  style={{ marginLeft: 10 }}
+                >
+                  {getIntlContent("SHENYU.COMMON.ADD")}
+                </Button>
+              </AuthButton>
+              <AuthButton perms="system:aiProxyApiKey:disable">
+                <Button
+                  type="primary"
+                  onClick={onBatchEnable}
+                  style={{ marginLeft: 10 }}
+                >
+                  {getIntlContent("SHENYU.PLUGIN.SELECTOR.BATCH.OPENED")}
+                </Button>
+              </AuthButton>
+              <AuthButton perms="system:aiProxyApiKey:disable">
+                <Button
+                  type="primary"
+                  onClick={onBatchDisable}
+                  style={{ marginLeft: 10 }}
+                >
+                  {getIntlContent("SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED")}
+                </Button>
+              </AuthButton>
+              <AuthButton perms="system:aiProxyApiKey:delete">
+                <Button
+                  type="primary"
+                  onClick={onBatchDeleteConfirm}
+                  style={{ marginLeft: 10 }}
+                >
+                  {getIntlContent("SHENYU.SYSTEM.DELETEDATA")}
+                </Button>
+              </AuthButton>
+            </div>
+          </div>
+
+          <Table
+            size="small"
+            style={{ marginTop: 20 }}
+            rowKey={(r) => r.id}
+            bordered
+            columns={columns}
+            dataSource={data}
+            rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
+            pagination={{
+              total,
+              showTotal: (t) => `${t}`,
+              showSizeChanger: true,
+              pageSizeOptions: ["10", "20", "50", "100"],
+              current: page,
+              pageSize,
+              onChange: (p, ps) => {
+                setPage(p);
+                setPageSize(ps);
+                fetchList(p, ps);
+              },
+              onShowSizeChange: (cp, ps) => {
+                setPage(1);
+                setPageSize(ps);
+                fetchList(1, ps);
+              },
+            }}
+          />
+        </Col>
+      </Row>
+
+      <CreateModal
+        visible={createOpen}
+        onCancel={() => setCreateOpen(false)}
+        onOk={onCreate}
+        defaultNamespace={namespaceId}
+        realApiKeyPreset={realKeyFromSelector}
+      />
+      <EditModal
+        visible={editOpen}
+        onCancel={() => setEditOpen(false)}
+        onOk={onEdit}
+        record={current}
+        defaultNamespace={namespaceId}
+      />
+    </div>
+  );
+}
+
+export default connect(({ global }) => ({
+  currentNamespaceId: global.currentNamespaceId,
+}))(ApiKeysPage);
diff --git a/src/routes/Plugin/Common/index.js 
b/src/routes/Plugin/Common/index.js
index b61e01f3..9f5601de 100755
--- a/src/routes/Plugin/Common/index.js
+++ b/src/routes/Plugin/Common/index.js
@@ -38,6 +38,7 @@ import {
   getUpdateModal,
   updateNamespacePluginsEnabledByNamespace,
 } from "../../../utils/namespacePlugin";
+import ApiKeysPage from "../AiProxy/ApiKeys";
 
 const { Search } = Input;
 const { Title } = Typography;
@@ -61,6 +62,7 @@ export default class Common extends Component {
       selectorName: undefined,
       ruleName: undefined,
       isPluginEnabled: false,
+      showApiKeyManage: false,
     };
   }
 
@@ -715,6 +717,13 @@ export default class Common extends Component {
   deleteSelector = (record) => {
     const { dispatch, plugins, currentNamespaceId } = this.props;
     const { selectorPage, selectorPageSize } = this.state;
+    // Clear local selected state if the deleted selector is currently 
selected to avoid stale selection after deletion
+    if (
+      Array.isArray(this.state.selectorSelectedRowKeys) &&
+      this.state.selectorSelectedRowKeys.includes(record.id)
+    ) {
+      this.setState({ selectorSelectedRowKeys: [] });
+    }
     let name = this.props.match.params ? this.props.match.params.id : "";
     const pluginId = this.getPluginId(plugins, name);
     dispatch({
@@ -763,9 +772,7 @@ export default class Common extends Component {
     const { selectorPageSize } = this.state;
     dispatch({
       type: "common/saveCurrentSelector",
-      payload: {
-        currentSelector: record,
-      },
+      payload: { currentSelector: record },
     });
     dispatch({
       type: "common/fetchRule",
@@ -1170,154 +1177,241 @@ export default class Common extends Component {
             </AuthButton>
           </div>
         </Row>
-        <Row gutter={20}>
-          <Col span={10}>
-            <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.TITLE")}</h3>
-            <div className="table-header">
-              <div className={styles.headerSearch}>
-                <AuthButton perms={`plugin:${name}Selector:query`}>
-                  <Search
-                    className={styles.search}
-                    style={{ minWidth: "130px" }}
-                    placeholder={getIntlContent(
-                      "SHENYU.PLUGIN.SEARCH.SELECTOR.NAME",
-                    )}
-                    enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
-                    size="default"
-                    onChange={this.searchSelectorOnchange}
-                    onSearch={this.searchSelector}
-                  />
-                </AuthButton>
-                <AuthButton perms={`plugin:${name}Selector:add`}>
-                  <Button type="primary" onClick={this.addSelector}>
-                    {getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.ADD")}
-                  </Button>
-                </AuthButton>
-                <AuthButton perms={`plugin:${name}Selector:edit`}>
-                  <Button
-                    type="primary"
-                    onClick={this.openSelectorClick}
-                    style={{ marginLeft: 10 }}
-                  >
-                    {getIntlContent(
-                      selectorList.some(
-                        (selector) =>
-                          selectorSelectedRowKeys.includes(selector.id) &&
-                          selector.enabled,
-                      )
-                        ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
-                        : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
-                    )}
-                  </Button>
-                </AuthButton>
+        {name === "aiProxy" && this.state.showApiKeyManage ? (
+          (() => {
+            const selectedId =
+              this.state.selectorSelectedRowKeys &&
+              this.state.selectorSelectedRowKeys.length === 1
+                ? this.state.selectorSelectedRowKeys[0]
+                : this.props.currentSelector?.id;
+            const selectedSelector =
+              (this.props.selectorList || []).find(
+                (s) => s.id === selectedId,
+              ) || this.props.currentSelector;
+            return (
+              <ApiKeysPage
+                initialSelectorId={selectedId}
+                initialNamespaceId={this.props.currentNamespaceId}
+                pluginId={this.state.pluginId}
+                currentSelector={selectedSelector}
+                onBack={() => this.setState({ showApiKeyManage: false })}
+              />
+            );
+          })()
+        ) : (
+          <Row gutter={20}>
+            <Col span={10}>
+              <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.TITLE")}</h3>
+              <div className="table-header">
+                <div className={styles.headerSearch}>
+                  <AuthButton perms={`plugin:${name}Selector:query`}>
+                    <Search
+                      className={styles.search}
+                      style={{ minWidth: "130px" }}
+                      placeholder={getIntlContent(
+                        "SHENYU.PLUGIN.SEARCH.SELECTOR.NAME",
+                      )}
+                      enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
+                      size="default"
+                      onChange={this.searchSelectorOnchange}
+                      onSearch={this.searchSelector}
+                    />
+                  </AuthButton>
+                  <AuthButton perms={`plugin:${name}Selector:add`}>
+                    <Button type="primary" onClick={this.addSelector}>
+                      {getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.ADD")}
+                    </Button>
+                  </AuthButton>
+                  <AuthButton perms={`plugin:${name}Selector:edit`}>
+                    <Button
+                      type="primary"
+                      onClick={this.openSelectorClick}
+                      style={{ marginLeft: 10 }}
+                    >
+                      {getIntlContent(
+                        selectorList.some(
+                          (selector) =>
+                            selectorSelectedRowKeys.includes(selector.id) &&
+                            selector.enabled,
+                        )
+                          ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
+                          : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
+                      )}
+                    </Button>
+                  </AuthButton>
+                  {name === "aiProxy" ? (
+                    <AuthButton perms="system:aiProxyApiKey:list">
+                      <Button
+                        type="primary"
+                        onClick={() => {
+                          const keys = this.state.selectorSelectedRowKeys;
+                          if (!keys || keys.length === 0) {
+                            message.destroy();
+                            message.warn(
+                              getIntlContent(
+                                "SHENYU.COMMON.WARN.INPUT_SELECTOR",
+                              ),
+                            );
+                            return;
+                          }
+                          if (keys.length > 1) {
+                            message.destroy();
+                            message.warn(
+                              getIntlContent(
+                                "APIPROXY.APIKEY.ONLY_ONE_SELECTOR",
+                              ),
+                            );
+                            return;
+                          }
+                          const selectedId = keys[0];
+                          const selected =
+                            (this.props.selectorList || []).find(
+                              (s) => s.id === selectedId,
+                            ) || this.props.currentSelector;
+                          const readProxyEnabled = () => {
+                            const handle = selected && selected.handle;
+                            if (!handle) return undefined;
+                            if (typeof handle === "string") {
+                              try {
+                                const obj = JSON.parse(handle);
+                                return obj && obj.proxyEnabled;
+                              } catch (err) {
+                                return undefined;
+                              }
+                            }
+                            return handle.proxyEnabled;
+                          };
+                          const proxyEnabled = readProxyEnabled();
+                          if (`${proxyEnabled}` !== "true") {
+                            message.destroy();
+                            message.warn(
+                              getIntlContent(
+                                "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED",
+                              ),
+                            );
+                            return;
+                          }
+                          this.setState({ showApiKeyManage: true });
+                        }}
+                        style={{ marginLeft: 10 }}
+                      >
+                        {getIntlContent("APIPROXY.APIKEY.MANAGE") ||
+                          "Proxy API Key Manage"}
+                      </Button>
+                    </AuthButton>
+                  ) : null}
+                </div>
               </div>
-            </div>
-            <Table
-              size="small"
-              onRow={(record) => {
-                return {
-                  onClick: () => {
-                    this.rowClick(record);
-                  },
-                };
-              }}
-              style={{ marginTop: 30 }}
-              bordered
-              columns={selectColumns}
-              dataSource={selectorList}
-              rowSelection={selectorRowSelection}
-              pagination={{
-                total: selectorTotal,
-                showTotal: (showTotal) => `${showTotal}`,
-                showSizeChanger: true,
-                pageSizeOptions: ["12", "20", "50", "100"],
-                current: selectorPage,
-                pageSize: selectorPageSize,
-                onChange: this.pageSelectorChange,
-                onShowSizeChange: this.pageSelectorChangeSize,
-              }}
-              rowClassName={(item) => {
-                if (currentSelector && currentSelector.id === item.id) {
-                  return "table-selected";
-                } else {
-                  return "";
-                }
-              }}
-            />
-          </Col>
-          <Col span={14}>
-            <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.RULE.LIST")}</h3>
+              <Table
+                size="small"
+                onRow={(record) => {
+                  return {
+                    onClick: () => {
+                      this.rowClick(record);
+                    },
+                  };
+                }}
+                style={{ marginTop: 30 }}
+                bordered
+                columns={selectColumns}
+                dataSource={selectorList}
+                rowSelection={selectorRowSelection}
+                pagination={{
+                  total: selectorTotal,
+                  showTotal: (showTotal) => `${showTotal}`,
+                  showSizeChanger: true,
+                  pageSizeOptions: ["12", "20", "50", "100"],
+                  current: selectorPage,
+                  pageSize: selectorPageSize,
+                  onChange: this.pageSelectorChange,
+                  onShowSizeChange: this.pageSelectorChangeSize,
+                }}
+                rowClassName={(item) => {
+                  if (currentSelector && currentSelector.id === item.id) {
+                    return "table-selected";
+                  } else {
+                    return "";
+                  }
+                }}
+              />
+            </Col>
+            <Col span={14}>
+              <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.RULE.LIST")}</h3>
 
-            <div className="table-header">
-              <div style={{ display: "flex", alignItems: "center" }}>
-                <AuthButton perms={`plugin:${name}:modify`}>
-                  <Button
-                    icon="reload"
-                    onClick={this.asyncClick}
-                    type="primary"
-                  >
-                    {getIntlContent("SHENYU.COMMON.SYN")} {name}
-                  </Button>
-                </AuthButton>
-              </div>
+              <div className="table-header">
+                <div style={{ display: "flex", alignItems: "center" }}>
+                  <AuthButton perms={`plugin:${name}:modify`}>
+                    <Button
+                      icon="reload"
+                      onClick={this.asyncClick}
+                      type="primary"
+                    >
+                      {getIntlContent("SHENYU.COMMON.SYN")} {name}
+                    </Button>
+                  </AuthButton>
+                </div>
 
-              <div className={`${styles.headerSearch} ${styles.marginLeft10}`}>
-                <AuthButton perms={`plugin:${name}Rule:query`}>
-                  <Search
-                    className={styles.search}
-                    placeholder={getIntlContent(
-                      "SHENYU.PLUGIN.SEARCH.RULE.NAME",
-                    )}
-                    enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
-                    size="default"
-                    onChange={this.searchRuleOnchange}
-                    onSearch={this.searchRule}
-                  />
-                </AuthButton>
-                <AuthButton perms={`plugin:${name}Rule:add`}>
-                  <Button type="primary" onClick={this.addRule}>
-                    {getIntlContent("SHENYU.COMMON.ADD.RULE")}
-                  </Button>
-                </AuthButton>
-                <AuthButton perms={`plugin:${name}Rule:edit`}>
-                  <Button
-                    type="primary"
-                    onClick={this.openRuleClick}
-                    style={{ marginLeft: 10 }}
-                  >
-                    {getIntlContent(
-                      ruleList.some(
-                        (rule) =>
-                          ruleSelectedRowKeys.includes(rule.id) && 
rule.enabled,
-                      )
-                        ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
-                        : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
-                    )}
-                  </Button>
-                </AuthButton>
+                <div
+                  className={`${styles.headerSearch} ${styles.marginLeft10}`}
+                >
+                  <AuthButton perms={`plugin:${name}Rule:query`}>
+                    <Search
+                      className={styles.search}
+                      placeholder={getIntlContent(
+                        "SHENYU.PLUGIN.SEARCH.RULE.NAME",
+                      )}
+                      enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
+                      size="default"
+                      onChange={this.searchRuleOnchange}
+                      onSearch={this.searchRule}
+                    />
+                  </AuthButton>
+                  <AuthButton perms={`plugin:${name}Rule:add`}>
+                    <Button type="primary" onClick={this.addRule}>
+                      {getIntlContent("SHENYU.COMMON.ADD.RULE")}
+                    </Button>
+                  </AuthButton>
+                  <AuthButton perms={`plugin:${name}Rule:edit`}>
+                    <Button
+                      type="primary"
+                      onClick={this.openRuleClick}
+                      style={{ marginLeft: 10 }}
+                    >
+                      {getIntlContent(
+                        ruleList.some(
+                          (rule) =>
+                            ruleSelectedRowKeys.includes(rule.id) &&
+                            rule.enabled,
+                        )
+                          ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
+                          : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
+                      )}
+                    </Button>
+                  </AuthButton>
+                </div>
               </div>
-            </div>
-            <Table
-              size="small"
-              style={{ marginTop: 30 }}
-              bordered
-              columns={rulesColumns}
-              expandedRowRender={expandedRowRender}
-              dataSource={ruleList}
-              rowSelection={ruleRowSelection}
-              pagination={{
-                total: ruleTotal,
-                showTotal: (showTotal) => `${showTotal}`,
-                showSizeChanger: true,
-                pageSizeOptions: ["12", "20", "50", "100"],
-                current: rulePage,
-                pageSize: rulePageSize,
-                onChange: this.pageRuleChange,
-                onShowSizeChange: this.pageRuleChangeSize,
-              }}
-            />
-          </Col>
-        </Row>
+              <Table
+                size="small"
+                style={{ marginTop: 30 }}
+                bordered
+                columns={rulesColumns}
+                expandedRowRender={expandedRowRender}
+                dataSource={ruleList}
+                rowSelection={ruleRowSelection}
+                pagination={{
+                  total: ruleTotal,
+                  showTotal: (showTotal) => `${showTotal}`,
+                  showSizeChanger: true,
+                  pageSizeOptions: ["12", "20", "50", "100"],
+                  current: rulePage,
+                  pageSize: rulePageSize,
+                  onChange: this.pageRuleChange,
+                  onShowSizeChange: this.pageRuleChangeSize,
+                }}
+              />
+            </Col>
+          </Row>
+        )}
         {popup}
       </div>
     );
diff --git a/src/services/api.js b/src/services/api.js
index 3e5791a0..53cd67d8 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1478,3 +1478,69 @@ export async function batchDeleteRegistry(params) {
     body: [...params.list],
   });
 }
+
+/* ===== AI Proxy - API Key ===== */
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey
+ * body: { description?, enabled?, namespaceId }
+ */
+export async function addAiProxyApiKey(params) {
+  const { selectorId, description, enabled = true, namespaceId } = params;
+  return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey`, {
+    method: `POST`,
+    body: { description, enabled, namespaceId },
+  });
+}
+
+/**
+ * GET 
/selector/{selectorId}/ai-proxy-apikey?namespaceId=&currentPage=&pageSize=&proxyApiKey=
+ */
+export async function getAiProxyApiKeys(params) {
+  const { selectorId, namespaceId, currentPage, pageSize, proxyApiKey } =
+    params;
+  const query = stringify({ namespaceId, currentPage, pageSize, proxyApiKey });
+  return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey?${query}`, 
{
+    method: `GET`,
+  });
+}
+
+/**
+ * PUT /selector/{selectorId}/ai-proxy-apikey/{id}
+ */
+export async function updateAiProxyApiKey(params) {
+  const { selectorId, id, description, enabled, namespaceId } = params;
+  return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey/${id}`, {
+    method: `PUT`,
+    body: { description, enabled, namespaceId },
+  });
+}
+
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey/batchDelete
+ * body: { ids: [] }
+ */
+export async function batchDeleteAiProxyApiKeys(params) {
+  const { selectorId, ids } = params;
+  return request(
+    `${baseUrl}/selector/${selectorId}/ai-proxy-apikey/batchDelete`,
+    {
+      method: `POST`,
+      body: { ids },
+    },
+  );
+}
+
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey/batchEnabled
+ * body: { ids: [], enabled: boolean }
+ */
+export async function batchEnableAiProxyApiKeys(params) {
+  const { selectorId, ids, enabled } = params;
+  return request(
+    `${baseUrl}/selector/${selectorId}/ai-proxy-apikey/batchEnabled`,
+    {
+      method: `POST`,
+      body: { ids, enabled },
+    },
+  );
+}


Reply via email to