-
{tt('Codex 用量')}
- {statusTag}
+ return (
+
+
+
+ {tt('渠道:')}
+ {record?.name || '-'} ({tt('编号:')}
+ {record?.id || '-'})
+
+
+ {statusTag}
+
+
- ),
+
+
+
+ {tt('上游状态码:')}
+ {upstreamStatus ?? '-'}
+
+
+
+
+
+
+
+
+
+
+
{tt('原始 JSON')}
+
+
+
+ {rawText}
+
+
+
+ );
+};
+
+const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
+ const tt = typeof t === 'function' ? t : (v) => v;
+ const [loading, setLoading] = useState(!initialPayload);
+ const [payload, setPayload] = useState(initialPayload ?? null);
+ const hasShownErrorRef = useRef(false);
+ const mountedRef = useRef(true);
+ const recordId = record?.id;
+
+ const fetchUsage = useCallback(async () => {
+ if (!recordId) {
+ if (mountedRef.current) setPayload(null);
+ return;
+ }
+
+ if (mountedRef.current) setLoading(true);
+ try {
+ const res = await API.get(`/api/channel/${recordId}/codex/usage`, {
+ skipErrorHandler: true,
+ });
+ if (!mountedRef.current) return;
+ setPayload(res?.data ?? null);
+ if (!res?.data?.success && !hasShownErrorRef.current) {
+ hasShownErrorRef.current = true;
+ showError(tt('获取用量失败'));
+ }
+ } catch (error) {
+ if (!mountedRef.current) return;
+ if (!hasShownErrorRef.current) {
+ hasShownErrorRef.current = true;
+ showError(tt('获取用量失败'));
+ }
+ setPayload({ success: false, message: String(error) });
+ } finally {
+ if (mountedRef.current) setLoading(false);
+ }
+ }, [recordId, tt]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (initialPayload) return;
+ fetchUsage().catch(() => {});
+ }, [fetchUsage, initialPayload]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!payload) {
+ return (
+
+
{tt('获取用量失败')}
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
+ const tt = typeof t === 'function' ? t : (v) => v;
+
+ Modal.info({
+ title: tt('Codex 用量'),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
content: (
-
-
-
- {tt('渠道:')}
- {record?.name || '-'} ({tt('编号:')}
- {record?.id || '-'})
-
-
- {tt('上游状态码:')}
- {upstreamStatus ?? '-'}
-
-
-
-
-
-
-
-
-
-
-
{tt('原始 JSON')}
-
-
-
- {rawText}
-
-
-
+
),
footer: (
diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
index b3096c286..4c7fb1d84 100644
--- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
@@ -40,7 +40,7 @@ import {
renderClaudeModelPrice,
renderModelPrice,
} from '../../../helpers';
-import { IconHelpCircle } from '@douyinfe/semi-icons';
+import { IconHelpCircle, IconStarStroked } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react';
const colors = [
@@ -498,6 +498,7 @@ export const getLogsColumns = ({
return <>>;
}
let content = t('渠道') + `:${record.channel}`;
+ let affinity = null;
if (record.other !== '') {
let other = JSON.parse(record.other);
if (other === null) {
@@ -513,9 +514,55 @@ export const getLogsColumns = ({
let useChannelStr = useChannel.join('->');
content = t('渠道') + `:${useChannelStr}`;
}
+ if (other.admin_info.channel_affinity) {
+ affinity = other.admin_info.channel_affinity;
+ }
}
}
- return isAdminUser ?
{content}
: <>>;
+ return isAdminUser ? (
+
+ {content}
+ {affinity ? (
+
+ {t('渠道亲和性')}
+
+
+ {t('规则')}:{affinity.rule_name || '-'}
+
+
+
+
+ {t('分组')}:{affinity.selected_group || '-'}
+
+
+
+
+ {t('Key')}:
+ {(affinity.key_source || '-') +
+ ':' +
+ (affinity.key_path || affinity.key_key || '-') +
+ (affinity.key_fp ? `#${affinity.key_fp}` : '')}
+
+
+
+ }
+ >
+
+
+
+
+ {t('优选')}
+
+
+
+
+ ) : null}
+
+ ) : (
+ <>>
+ );
},
},
{
@@ -552,9 +599,13 @@ export const getLogsColumns = ({
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
- other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
+ other.cache_creation_ratio_5m ||
+ other.cache_creation_ratio ||
+ 1.0,
other.cache_creation_tokens_1h || 0,
- other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
+ other.cache_creation_ratio_1h ||
+ other.cache_creation_ratio ||
+ 1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx
index 5e1feb162..1ce0785fb 100644
--- a/web/src/hooks/channels/useChannelsData.jsx
+++ b/web/src/hooks/channels/useChannelsData.jsx
@@ -747,28 +747,15 @@ export const useChannelsData = () => {
const updateChannelBalance = async (record) => {
if (record?.type === 57) {
- try {
- const res = await API.get(`/api/channel/${record.id}/codex/usage`, {
- skipErrorHandler: true,
- });
- if (!res?.data?.success) {
- console.error('Codex usage fetch failed:', res?.data?.message);
- showError(t('获取用量失败'));
- }
- openCodexUsageModal({
- t,
- record,
- payload: res?.data,
- onCopy: async (text) => {
- const ok = await copy(text);
- if (ok) showSuccess(t('已复制'));
- else showError(t('复制失败'));
- },
- });
- } catch (error) {
- console.error('Codex usage fetch error:', error);
- showError(t('获取用量失败'));
- }
+ openCodexUsageModal({
+ t,
+ record,
+ onCopy: async (text) => {
+ const ok = await copy(text);
+ if (ok) showSuccess(t('已复制'));
+ else showError(t('复制失败'));
+ },
+ });
return;
}
diff --git a/web/src/index.css b/web/src/index.css
index dff5360b9..229095068 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -818,6 +818,34 @@ html.dark .with-pastel-balls::before {
padding: 10px !important;
}
+/* ==================== 使用日志: channel affinity tag ==================== */
+.semi-tag.channel-affinity-tag {
+ border: 1px solid rgba(var(--semi-cyan-5), 0.35);
+ background-color: rgba(var(--semi-cyan-5), 0.15);
+ color: rgba(var(--semi-cyan-9), 1);
+ cursor: help;
+ transition:
+ background-color 120ms ease,
+ border-color 120ms ease,
+ box-shadow 120ms ease;
+}
+
+.semi-tag.channel-affinity-tag:hover {
+ background-color: rgba(var(--semi-cyan-5), 0.22);
+ border-color: rgba(var(--semi-cyan-5), 0.6);
+ box-shadow: 0 0 0 2px rgba(var(--semi-cyan-5), 0.18);
+}
+
+.semi-tag.channel-affinity-tag:active {
+ background-color: rgba(var(--semi-cyan-5), 0.28);
+}
+
+.semi-tag.channel-affinity-tag .channel-affinity-tag-content {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
/* ==================== 自定义圆角样式 ==================== */
.semi-radio,
.semi-tagInput,
diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx
index 3d4cfd56e..9878875c7 100644
--- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx
+++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx
@@ -49,6 +49,7 @@ const chatCompletionsToResponsesPolicyExample = JSON.stringify(
enabled: true,
all_channels: false,
channel_ids: [1, 2],
+ channel_types: [1],
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
},
null,
diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
new file mode 100644
index 000000000..86c2bc321
--- /dev/null
+++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
@@ -0,0 +1,1139 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see
.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ Banner,
+ Button,
+ Col,
+ Collapse,
+ Divider,
+ Form,
+ Input,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Spin,
+ Table,
+ Tag,
+ Typography,
+} from '@douyinfe/semi-ui';
+import {
+ IconClose,
+ IconDelete,
+ IconEdit,
+ IconPlus,
+ IconRefresh,
+} from '@douyinfe/semi-icons';
+import {
+ API,
+ compareObjects,
+ showError,
+ showSuccess,
+ showWarning,
+ toBoolean,
+ verifyJSON,
+} from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const KEY_ENABLED = 'channel_affinity_setting.enabled';
+const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
+const KEY_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
+const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
+const KEY_RULES = 'channel_affinity_setting.rules';
+
+const KEY_SOURCE_TYPES = [
+ { label: 'context_int', value: 'context_int' },
+ { label: 'context_string', value: 'context_string' },
+ { label: 'gjson', value: 'gjson' },
+];
+
+const RULE_TEMPLATES = {
+ codex: {
+ name: 'codex优选',
+ model_regex: ['^gpt-.*$'],
+ path_regex: ['/v1/responses'],
+ key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
+ value_regex: '',
+ ttl_seconds: 0,
+ include_using_group: true,
+ include_rule_name: true,
+ },
+ claudeCode: {
+ name: 'claude-code优选',
+ model_regex: ['^claude-.*$'],
+ path_regex: ['/v1/messages'],
+ key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
+ value_regex: '',
+ ttl_seconds: 0,
+ include_using_group: true,
+ include_rule_name: true,
+ },
+};
+
+const CONTEXT_KEY_PRESETS = [
+ { key: 'id', label: 'id(用户 ID)' },
+ { key: 'token_id', label: 'token_id' },
+ { key: 'token_key', label: 'token_key' },
+ { key: 'token_group', label: 'token_group' },
+ { key: 'group', label: 'group(using_group)' },
+ { key: 'username', label: 'username' },
+ { key: 'user_group', label: 'user_group' },
+ { key: 'user_email', label: 'user_email' },
+ { key: 'specific_channel_id', label: 'specific_channel_id' },
+];
+
+const RULES_JSON_PLACEHOLDER = `[
+ {
+ "name": "prefer-by-conversation-id",
+ "model_regex": ["^gpt-.*$"],
+ "path_regex": ["/v1/chat/completions"],
+ "user_agent_include": ["curl", "PostmanRuntime"],
+ "key_sources": [
+ { "type": "gjson", "path": "metadata.conversation_id" },
+ { "type": "context_string", "key": "conversation_id" }
+ ],
+ "value_regex": "^[-0-9A-Za-z._:]{1,128}$",
+ "ttl_seconds": 600,
+ "include_using_group": true,
+ "include_rule_name": true
+ }
+]`;
+
+const normalizeStringList = (text) => {
+ if (!text) return [];
+ return text
+ .split('\n')
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+};
+
+const stringifyPretty = (v) => JSON.stringify(v, null, 2);
+const stringifyCompact = (v) => JSON.stringify(v);
+
+const parseRulesJson = (jsonString) => {
+ try {
+ const parsed = JSON.parse(jsonString || '[]');
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map((rule, index) => ({
+ id: index,
+ ...(rule || {}),
+ }));
+ } catch (e) {
+ return [];
+ }
+};
+
+const rulesToJson = (rules) => {
+ const payload = (rules || []).map((r) => {
+ const { id, ...rest } = r || {};
+ return rest;
+ });
+ return stringifyPretty(payload);
+};
+
+const normalizeKeySource = (src) => {
+ const type = (src?.type || '').trim();
+ const key = (src?.key || '').trim();
+ const path = (src?.path || '').trim();
+ return { type, key, path };
+};
+
+const makeUniqueName = (existingNames, baseName) => {
+ const base = (baseName || '').trim() || 'rule';
+ if (!existingNames.has(base)) return base;
+ for (let i = 2; i < 1000; i++) {
+ const n = `${base}-${i}`;
+ if (!existingNames.has(n)) return n;
+ }
+ return `${base}-${Date.now()}`;
+};
+
+const tryParseRulesJsonArray = (jsonString) => {
+ const raw = jsonString || '[]';
+ if (!verifyJSON(raw)) return { ok: false, message: 'Rules JSON is invalid' };
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed))
+ return { ok: false, message: 'Rules JSON must be an array' };
+ return { ok: true, value: parsed };
+ } catch (e) {
+ return { ok: false, message: 'Rules JSON is invalid' };
+ }
+};
+
+export default function SettingsChannelAffinity(props) {
+ const { t } = useTranslation();
+ const { Text } = Typography;
+ const [loading, setLoading] = useState(false);
+
+ const [cacheLoading, setCacheLoading] = useState(false);
+ const [cacheStats, setCacheStats] = useState({
+ enabled: false,
+ total: 0,
+ unknown: 0,
+ by_rule_name: {},
+ cache_capacity: 0,
+ cache_algo: '',
+ });
+
+ const [inputs, setInputs] = useState({
+ [KEY_ENABLED]: false,
+ [KEY_SWITCH_ON_SUCCESS]: true,
+ [KEY_MAX_ENTRIES]: 100000,
+ [KEY_DEFAULT_TTL]: 3600,
+ [KEY_RULES]: '[]',
+ });
+ const refForm = useRef();
+ const [inputsRow, setInputsRow] = useState(inputs);
+ const [editMode, setEditMode] = useState('visual');
+ const prevEditModeRef = useRef(editMode);
+
+ const [rules, setRules] = useState([]);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [editingRule, setEditingRule] = useState(null);
+ const [isEdit, setIsEdit] = useState(false);
+ const modalFormRef = useRef();
+ const [modalInitValues, setModalInitValues] = useState(null);
+ const [modalFormKey, setModalFormKey] = useState(0);
+ const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
+
+ const effectiveDefaultTTLSeconds =
+ Number(inputs?.[KEY_DEFAULT_TTL] || 0) > 0
+ ? Number(inputs?.[KEY_DEFAULT_TTL] || 0)
+ : 3600;
+
+ const buildModalFormValues = (rule) => {
+ const r = rule || {};
+ return {
+ name: r.name || '',
+ model_regex_text: (r.model_regex || []).join('\n'),
+ path_regex_text: (r.path_regex || []).join('\n'),
+ user_agent_include_text: (r.user_agent_include || []).join('\n'),
+ value_regex: r.value_regex || '',
+ ttl_seconds: Number(r.ttl_seconds || 0),
+ include_using_group: r.include_using_group ?? true,
+ include_rule_name: r.include_rule_name ?? true,
+ };
+ };
+
+ const refreshCacheStats = async () => {
+ try {
+ setCacheLoading(true);
+ const res = await API.get('/api/option/channel_affinity_cache', {
+ disableDuplicate: true,
+ });
+ const { success, message, data } = res.data;
+ if (!success) return showError(t(message));
+ setCacheStats(data || {});
+ } catch (e) {
+ showError(t('刷新缓存统计失败'));
+ } finally {
+ setCacheLoading(false);
+ }
+ };
+
+ const confirmClearAllCache = () => {
+ Modal.confirm({
+ title: t('确认清空全部渠道亲和性缓存'),
+ content: (
+
+ {t('将删除所有仍在内存中的渠道亲和性缓存条目。')}
+
+ ),
+ onOk: async () => {
+ const res = await API.delete('/api/option/channel_affinity_cache', {
+ params: { all: true },
+ });
+ const { success, message } = res.data;
+ if (!success) {
+ showError(t(message));
+ return;
+ }
+ showSuccess(t('已清空'));
+ await refreshCacheStats();
+ },
+ });
+ };
+
+ const confirmClearRuleCache = (rule) => {
+ const name = (rule?.name || '').trim();
+ if (!name) return;
+ if (!rule?.include_rule_name) {
+ showWarning(
+ t('该规则未启用“作用域:包含规则名称”,无法按规则清空缓存。'),
+ );
+ return;
+ }
+ Modal.confirm({
+ title: t('确认清空该规则缓存'),
+ content: (
+
+ {t('规则')}: {name}
+
+ ),
+ onOk: async () => {
+ const res = await API.delete('/api/option/channel_affinity_cache', {
+ params: { rule_name: name },
+ });
+ const { success, message } = res.data;
+ if (!success) {
+ showError(t(message));
+ return;
+ }
+ showSuccess(t('已清空'));
+ await refreshCacheStats();
+ },
+ });
+ };
+
+ const setRulesJsonToForm = (jsonString) => {
+ if (!refForm.current) return;
+ // Use setValue instead of setValues. Semi Form's setValues assigns undefined
+ // to every registered field not included in the payload, which can wipe other inputs.
+ refForm.current.setValue(KEY_RULES, jsonString || '[]');
+ };
+
+ const switchToJsonMode = () => {
+ // Ensure a stable source of truth when entering JSON mode.
+ // Semi Form may ignore setValues() for an unmounted field, so we seed state first.
+ const jsonString = rulesToJson(rules);
+ setInputs((prev) => ({ ...(prev || {}), [KEY_RULES]: jsonString }));
+ setEditMode('json');
+ };
+
+ const switchToVisualMode = () => {
+ const validation = tryParseRulesJsonArray(inputs[KEY_RULES] || '[]');
+ if (!validation.ok) {
+ showError(t(validation.message));
+ return;
+ }
+ setEditMode('visual');
+ };
+
+ const updateRulesState = (nextRules) => {
+ setRules(nextRules);
+ const jsonString = rulesToJson(nextRules);
+ setInputs((prev) => ({ ...prev, [KEY_RULES]: jsonString }));
+ if (refForm.current && editMode === 'json') {
+ refForm.current.setValue(KEY_RULES, jsonString);
+ }
+ };
+
+ const appendCodexAndClaudeCodeTemplates = () => {
+ const doAppend = () => {
+ const existingNames = new Set(
+ (rules || [])
+ .map((r) => (r?.name || '').trim())
+ .filter((x) => x.length > 0),
+ );
+
+ const templates = [RULE_TEMPLATES.codex, RULE_TEMPLATES.claudeCode].map(
+ (tpl) => {
+ const name = makeUniqueName(existingNames, tpl.name);
+ existingNames.add(name);
+ return { ...tpl, name };
+ },
+ );
+
+ const next = [...(rules || []), ...templates].map((r, idx) => ({
+ ...(r || {}),
+ id: idx,
+ }));
+ updateRulesState(next);
+ showSuccess(t('已填充模版'));
+ };
+
+ if ((rules || []).length === 0) {
+ doAppend();
+ return;
+ }
+
+ Modal.confirm({
+ title: t('填充 Codex / Claude Code 模版'),
+ content: (
+
+ {t('将追加 2 条规则到现有规则列表。')}
+
+ ),
+ onOk: doAppend,
+ });
+ };
+
+ const ruleColumns = [
+ {
+ title: t('名称'),
+ dataIndex: 'name',
+ render: (text) =>
{text || '-'},
+ },
+ {
+ title: t('模型正则'),
+ dataIndex: 'model_regex',
+ render: (list) =>
+ (list || []).length > 0
+ ? (list || []).slice(0, 3).map((v, idx) => (
+
+ {v}
+
+ ))
+ : '-',
+ },
+ {
+ title: t('路径正则'),
+ dataIndex: 'path_regex',
+ render: (list) =>
+ (list || []).length > 0
+ ? (list || []).slice(0, 2).map((v, idx) => (
+
+ {v}
+
+ ))
+ : '-',
+ },
+ {
+ title: t('User-Agent include'),
+ dataIndex: 'user_agent_include',
+ render: (list) =>
+ (list || []).length > 0
+ ? (list || []).slice(0, 2).map((v, idx) => (
+
+ {v}
+
+ ))
+ : '-',
+ },
+ {
+ title: t('Key 来源'),
+ dataIndex: 'key_sources',
+ render: (list) => {
+ const xs = list || [];
+ if (xs.length === 0) return '-';
+ return xs.slice(0, 3).map((src, idx) => {
+ const s = normalizeKeySource(src);
+ const detail = s.type === 'gjson' ? s.path : s.key;
+ return (
+
+ {s.type}:{detail}
+
+ );
+ });
+ },
+ },
+ {
+ title: t('TTL(秒)'),
+ dataIndex: 'ttl_seconds',
+ render: (v) =>
{Number(v || 0) || '-'},
+ },
+ {
+ title: t('缓存条目数'),
+ render: (_, record) => {
+ const name = (record?.name || '').trim();
+ if (!name || !record?.include_rule_name) {
+ return
N/A;
+ }
+ const n = Number(cacheStats?.by_rule_name?.[name] || 0);
+ return
{n};
+ },
+ },
+ {
+ title: t('作用域'),
+ render: (_, record) => {
+ const tags = [];
+ if (record?.include_using_group) tags.push('分组');
+ if (record?.include_rule_name) tags.push('规则');
+ if (tags.length === 0) return '-';
+ return tags.map((x) => (
+
+ {x}
+
+ ));
+ },
+ },
+ {
+ title: t('操作'),
+ render: (_, record) => (
+
+ }
+ theme='borderless'
+ type='warning'
+ disabled={!record?.include_rule_name}
+ title={t('清空该规则缓存')}
+ aria-label={t('清空该规则缓存')}
+ onClick={() => confirmClearRuleCache(record)}
+ />
+ }
+ theme='borderless'
+ title={t('编辑规则')}
+ aria-label={t('编辑规则')}
+ onClick={() => handleEditRule(record)}
+ />
+ }
+ theme='borderless'
+ type='danger'
+ title={t('删除规则')}
+ aria-label={t('删除规则')}
+ onClick={() => handleDeleteRule(record.id)}
+ />
+
+ ),
+ },
+ ];
+
+ const validateKeySources = (keySources) => {
+ const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
+ if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
+ for (const x of xs) {
+ if (x.type === 'context_int' || x.type === 'context_string') {
+ if (!x.key) return { ok: false, message: 'Key 不能为空' };
+ } else if (x.type === 'gjson') {
+ if (!x.path) return { ok: false, message: 'Path 不能为空' };
+ } else {
+ return { ok: false, message: 'Key 来源类型不合法' };
+ }
+ }
+ return { ok: true, value: xs };
+ };
+
+ const openAddModal = () => {
+ const nextRule = {
+ name: '',
+ model_regex: [],
+ path_regex: [],
+ user_agent_include: [],
+ key_sources: [{ type: 'gjson', path: '' }],
+ value_regex: '',
+ ttl_seconds: 0,
+ include_using_group: true,
+ include_rule_name: true,
+ };
+ setEditingRule(nextRule);
+ setIsEdit(false);
+ modalFormRef.current = null;
+ setModalInitValues(buildModalFormValues(nextRule));
+ setModalAdvancedActiveKey([]);
+ setModalFormKey((k) => k + 1);
+ setModalVisible(true);
+ };
+
+ const handleEditRule = (rule) => {
+ const r = rule || {};
+ const nextRule = {
+ ...r,
+ user_agent_include: Array.isArray(r.user_agent_include)
+ ? r.user_agent_include
+ : [],
+ key_sources: (r.key_sources || []).map(normalizeKeySource),
+ };
+ setEditingRule(nextRule);
+ setIsEdit(true);
+ modalFormRef.current = null;
+ setModalInitValues(buildModalFormValues(nextRule));
+ setModalAdvancedActiveKey([]);
+ setModalFormKey((k) => k + 1);
+ setModalVisible(true);
+ };
+
+ const handleDeleteRule = (id) => {
+ const next = (rules || []).filter((r) => r.id !== id);
+ updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
+ showSuccess(t('删除成功'));
+ };
+
+ const handleModalSave = async () => {
+ try {
+ const values = await modalFormRef.current.validate();
+ const modelRegex = normalizeStringList(values.model_regex_text);
+ if (modelRegex.length === 0) return showError(t('模型正则不能为空'));
+
+ const keySourcesValidation = validateKeySources(editingRule?.key_sources);
+ if (!keySourcesValidation.ok)
+ return showError(t(keySourcesValidation.message));
+
+ const userAgentInclude = normalizeStringList(
+ values.user_agent_include_text,
+ );
+
+ const rulePayload = {
+ id: isEdit ? editingRule.id : rules.length,
+ name: (values.name || '').trim(),
+ model_regex: modelRegex,
+ path_regex: normalizeStringList(values.path_regex_text),
+ key_sources: keySourcesValidation.value,
+ value_regex: (values.value_regex || '').trim(),
+ ttl_seconds: Number(values.ttl_seconds || 0),
+ include_using_group: !!values.include_using_group,
+ include_rule_name: !!values.include_rule_name,
+ ...(userAgentInclude.length > 0
+ ? { user_agent_include: userAgentInclude }
+ : {}),
+ };
+
+ if (!rulePayload.name) return showError(t('名称不能为空'));
+
+ const next = [...(rules || [])];
+ if (isEdit) {
+ let idx = next.findIndex((r) => r.id === editingRule?.id);
+ if (idx < 0 && editingRule?.name) {
+ idx = next.findIndex(
+ (r) => (r?.name || '').trim() === (editingRule?.name || '').trim(),
+ );
+ }
+ if (idx < 0) return showError(t('规则未找到,请刷新后重试'));
+ next[idx] = rulePayload;
+ } else {
+ next.push(rulePayload);
+ }
+ updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
+ setModalVisible(false);
+ setEditingRule(null);
+ setModalInitValues(null);
+ showSuccess(t('保存成功'));
+ } catch (e) {
+ showError(t('请检查输入'));
+ }
+ };
+
+ const updateKeySource = (index, patch) => {
+ const next = [...(editingRule?.key_sources || [])];
+ next[index] = normalizeKeySource({
+ ...(next[index] || {}),
+ ...(patch || {}),
+ });
+ setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
+ };
+
+ const addKeySource = () => {
+ const next = [...(editingRule?.key_sources || [])];
+ next.push({ type: 'gjson', path: '' });
+ setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
+ };
+
+ const removeKeySource = (index) => {
+ const next = [...(editingRule?.key_sources || [])].filter(
+ (_, i) => i !== index,
+ );
+ setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
+ };
+
+ async function onSubmit() {
+ const updateArray = compareObjects(inputs, inputsRow);
+ if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
+
+ if (!verifyJSON(inputs[KEY_RULES] || '[]'))
+ return showError(t('规则 JSON 格式不正确'));
+ let compactRules;
+ try {
+ compactRules = stringifyCompact(JSON.parse(inputs[KEY_RULES] || '[]'));
+ } catch (e) {
+ return showError(t('规则 JSON 格式不正确'));
+ }
+
+ const requestQueue = updateArray.map((item) => {
+ let value = '';
+ if (item.key === KEY_RULES) {
+ value = compactRules;
+ } else if (typeof inputs[item.key] === 'boolean') {
+ value = String(inputs[item.key]);
+ } else {
+ value = String(inputs[item.key] ?? '');
+ }
+ return API.put('/api/option/', { key: item.key, value });
+ });
+
+ setLoading(true);
+ Promise.all(requestQueue)
+ .then((res) => {
+ if (requestQueue.length === 1) {
+ if (res.includes(undefined)) return;
+ } else if (requestQueue.length > 1) {
+ if (res.includes(undefined))
+ return showError(t('部分保存失败,请重试'));
+ }
+ showSuccess(t('保存成功'));
+ props.refresh();
+ })
+ .catch(() => showError(t('保存失败,请重试')))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(() => {
+ const currentInputs = { ...inputs };
+ for (let key in props.options) {
+ if (
+ ![
+ KEY_ENABLED,
+ KEY_SWITCH_ON_SUCCESS,
+ KEY_MAX_ENTRIES,
+ KEY_DEFAULT_TTL,
+ KEY_RULES,
+ ].includes(key)
+ )
+ continue;
+ if (key === KEY_ENABLED)
+ currentInputs[key] = toBoolean(props.options[key]);
+ else if (key === KEY_SWITCH_ON_SUCCESS)
+ currentInputs[key] = toBoolean(props.options[key]);
+ else if (key === KEY_MAX_ENTRIES)
+ currentInputs[key] = Number(props.options[key] || 0) || 0;
+ else if (key === KEY_DEFAULT_TTL)
+ currentInputs[key] = Number(props.options[key] || 0) || 0;
+ else if (key === KEY_RULES) {
+ try {
+ const obj = JSON.parse(props.options[key] || '[]');
+ currentInputs[key] = stringifyPretty(obj);
+ } catch (e) {
+ currentInputs[key] = props.options[key] || '[]';
+ }
+ }
+ }
+ setInputs(currentInputs);
+ setInputsRow(structuredClone(currentInputs));
+ if (refForm.current) refForm.current.setValues(currentInputs);
+ setRules(parseRulesJson(currentInputs[KEY_RULES]));
+ refreshCacheStats();
+ }, [props.options]);
+
+ useEffect(() => {
+ const prevEditMode = prevEditModeRef.current;
+ prevEditModeRef.current = editMode;
+
+ // On switching from visual -> json, ensure the JSON editor is seeded.
+ // Semi Form may ignore setValues() for an unmounted field.
+ if (prevEditMode === editMode) return;
+ if (editMode !== 'json') return;
+ if (!refForm.current) return;
+ refForm.current.setValue(KEY_RULES, inputs[KEY_RULES] || '[]');
+ }, [editMode, inputs]);
+
+ useEffect(() => {
+ if (editMode === 'visual') {
+ setRules(parseRulesJson(inputs[KEY_RULES]));
+ }
+ }, [inputs[KEY_RULES], editMode]);
+
+ const banner = (
+
+ );
+
+ return (
+ <>
+
+
+ {banner}
+
+
+
+
+ setInputs({ ...inputs, [KEY_ENABLED]: value })
+ }
+ />
+
+ {t('启用后将优先复用上一次成功的渠道(粘滞选路)。')}
+
+
+
+
+ {t(
+ '内存缓存最大条目数。0 表示使用后端默认容量:100000。',
+ )}
+
+ }
+ onChange={(value) =>
+ setInputs({
+ ...inputs,
+ [KEY_MAX_ENTRIES]: Number(value || 0),
+ })
+ }
+ />
+
+
+
+ {t(
+ '规则 ttl_seconds 为 0 时使用。0 表示使用后端默认 TTL:3600 秒。',
+ )}
+
+ }
+ onChange={(value) =>
+ setInputs({
+ ...inputs,
+ [KEY_DEFAULT_TTL]: Number(value || 0),
+ })
+ }
+ />
+
+
+
+
+
+
+ setInputs({ ...inputs, [KEY_SWITCH_ON_SUCCESS]: value })
+ }
+ />
+
+ {t(
+ '如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。',
+ )}
+
+
+
+
+
+
+
+
+
+
+ } onClick={openAddModal}>
+ {t('新增规则')}
+
+
+ }
+ loading={cacheLoading}
+ onClick={refreshCacheStats}
+ >
+ {t('刷新缓存统计')}
+
+
+
+
+ {editMode === 'visual' ? (
+
+ ) : (
+ verifyJSON(value || '[]'),
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, [KEY_RULES]: value })
+ }
+ />
+ )}
+
+
+
+
+
{
+ setModalVisible(false);
+ setEditingRule(null);
+ setModalInitValues(null);
+ setModalAdvancedActiveKey([]);
+ }}
+ onOk={handleModalSave}
+ okText={t('保存')}
+ cancelText={t('取消')}
+ width={720}
+ >
+
+ setEditingRule((prev) => ({ ...(prev || {}), name: value }))
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {
+ const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
+ setModalAdvancedActiveKey(keys.filter(Boolean));
+ }}
+ >
+
+
+
+
+ {t(
+ '可选。匹配入口请求的 User-Agent;任意一行作为子串匹配(忽略大小写)即命中。',
+ )}
+
+ {t(
+ 'NewAPI 默认不会将入口请求的 User-Agent 透传到上游渠道;该条件仅用于识别访问本站点的客户端。',
+ )}
+
+ {t(
+ '为保证匹配准确,请确保客户端直连本站点(避免反向代理/网关改写 User-Agent)。',
+ )}
+
+ }
+ placeholder={'curl\nPostmanRuntime\nMyApp/…'}
+ autosize={{ minRows: 3, maxRows: 8 }}
+ />
+
+
+
+
+
+
+
+
+
+ {t('该规则的缓存保留时长;0 表示使用默认 TTL:')}
+ {effectiveDefaultTTLSeconds}
+ {t(' 秒。')}
+
+ }
+ />
+
+
+
+
+
+
+
+ {t(
+ '开启后,using_group 会参与 cache key(不同分组隔离)。',
+ )}
+
+
+
+
+
+ {t('开启后,规则名称会参与 cache key(不同规则隔离)。')}
+
+
+
+
+
+
+
+
+ {t('Key 来源')}
+ } onClick={addKeySource}>
+ {t('新增 Key 来源')}
+
+
+
+ {t(
+ 'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
+ )}
+
+
+
+ {t('常用上下文 Key(用于 context_*)')}:
+
+
+ {(CONTEXT_KEY_PRESETS || []).map((x) => (
+
+ {x.label}
+
+ ))}
+
+
+
+ (
+