mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
fix(openclaw): address code review findings across P0-P3 issues
- Add 25 missing i18n keys for OpenClawFormFields in all 3 locales (P0)
- Replace key={index} with stable crypto.randomUUID() keys in EnvPanel,
ToolsPanel, and OpenClawFormFields to prevent list state bugs (P1)
- Exclude openclaw from ProxyToggle/FailoverToggle in App.tsx (P1)
- Add merge_additive_config() for openclaw/opencode deep link imports (P1)
- Normalize serde(flatten) field naming to `extra` + HashMap (P2)
- Add directory existence check in remove_openclaw_provider_from_live (P2)
- Remove dead code in import_default_config and openclaw API methods (P2)
- Add duplicate key validation in EnvPanel before save (P2)
- Add openclawConfigDir to Settings type (P2)
- Add staleTime to OpenClaw query hooks (P3)
- Fix type-unsafe delete via destructuring in mutations.ts (P3)
This commit is contained in:
+1
-1
@@ -970,7 +970,7 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" && (
|
||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import type { OpenClawEnvConfig } from "@/types";
|
||||
|
||||
interface EnvEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isNew?: boolean;
|
||||
@@ -24,6 +25,7 @@ const EnvPanel: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (envData) {
|
||||
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}));
|
||||
@@ -34,9 +36,15 @@ const EnvPanel: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const env: OpenClawEnvConfig = {};
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const trimmedKey = entry.key.trim();
|
||||
if (trimmedKey) {
|
||||
if (seen.has(trimmedKey)) {
|
||||
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
|
||||
return;
|
||||
}
|
||||
seen.add(trimmedKey);
|
||||
env[trimmedKey] = entry.value;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +59,10 @@ const EnvPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries((prev) => [...prev, { key: "", value: "", isNew: true }]);
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
@@ -103,7 +114,7 @@ const EnvPanel: React.FC = () => {
|
||||
const visible = visibleKeys.has(visibilityId);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<div className="w-[200px] flex-shrink-0">
|
||||
<Input
|
||||
value={entry.key}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawToolsConfig } from "@/types";
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
|
||||
|
||||
const ToolsPanel: React.FC = () => {
|
||||
@@ -23,14 +28,24 @@ const ToolsPanel: React.FC = () => {
|
||||
const { data: toolsData, isLoading } = useOpenClawTools();
|
||||
const saveToolsMutation = useSaveOpenClawTools();
|
||||
const [config, setConfig] = useState<OpenClawToolsConfig>({});
|
||||
const [allowList, setAllowList] = useState<string[]>([]);
|
||||
const [denyList, setDenyList] = useState<string[]>([]);
|
||||
const [allowList, setAllowList] = useState<ListItem[]>([]);
|
||||
const [denyList, setDenyList] = useState<ListItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolsData) {
|
||||
setConfig(toolsData);
|
||||
setAllowList(toolsData.allow ?? []);
|
||||
setDenyList(toolsData.deny ?? []);
|
||||
setAllowList(
|
||||
(toolsData.allow ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
setDenyList(
|
||||
(toolsData.deny ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [toolsData]);
|
||||
|
||||
@@ -40,8 +55,8 @@ const ToolsPanel: React.FC = () => {
|
||||
const newConfig: OpenClawToolsConfig = {
|
||||
...other,
|
||||
profile: config.profile,
|
||||
allow: allowList.filter((s) => s.trim()),
|
||||
deny: denyList.filter((s) => s.trim()),
|
||||
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
|
||||
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
|
||||
};
|
||||
await saveToolsMutation.mutateAsync(newConfig);
|
||||
toast.success(t("openclaw.tools.saveSuccess"));
|
||||
@@ -54,20 +69,20 @@ const ToolsPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
const updateListItem = (
|
||||
list: string[],
|
||||
setList: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
value: string,
|
||||
) => {
|
||||
setList(list.map((item, i) => (i === index ? value : item)));
|
||||
setList((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, value } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeListItem = (
|
||||
list: string[],
|
||||
setList: React.Dispatch<React.SetStateAction<string[]>>,
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
) => {
|
||||
setList(list.filter((_, i) => i !== index));
|
||||
setList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -113,11 +128,11 @@ const ToolsPanel: React.FC = () => {
|
||||
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{allowList.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item}
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(allowList, setAllowList, index, e.target.value)
|
||||
updateListItem(setAllowList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
@@ -126,7 +141,7 @@ const ToolsPanel: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(allowList, setAllowList, index)}
|
||||
onClick={() => removeListItem(setAllowList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -135,7 +150,12 @@ const ToolsPanel: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAllowList((prev) => [...prev, ""])}
|
||||
onClick={() =>
|
||||
setAllowList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addAllow")}
|
||||
@@ -148,11 +168,11 @@ const ToolsPanel: React.FC = () => {
|
||||
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{denyList.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item}
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(denyList, setDenyList, index, e.target.value)
|
||||
updateListItem(setDenyList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
@@ -161,7 +181,7 @@ const ToolsPanel: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(denyList, setDenyList, index)}
|
||||
onClick={() => removeListItem(setDenyList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -170,7 +190,12 @@ const ToolsPanel: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDenyList((prev) => [...prev, ""])}
|
||||
onClick={() =>
|
||||
setDenyList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addDeny")}
|
||||
|
||||
@@ -309,6 +309,7 @@ export function AddProviderDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode/OpenClaw: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -64,6 +64,21 @@ export function OpenClawFormFields({
|
||||
{},
|
||||
);
|
||||
|
||||
// Stable key tracking for models list
|
||||
const modelKeysRef = useRef<string[]>([]);
|
||||
const getModelKeys = useCallback(() => {
|
||||
// Grow keys array if models were added externally
|
||||
while (modelKeysRef.current.length < models.length) {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
}
|
||||
// Shrink if models were removed externally
|
||||
if (modelKeysRef.current.length > models.length) {
|
||||
modelKeysRef.current.length = models.length;
|
||||
}
|
||||
return modelKeysRef.current;
|
||||
}, [models.length]);
|
||||
const modelKeys = getModelKeys();
|
||||
|
||||
// Toggle advanced section for a model
|
||||
const toggleModelAdvanced = (index: number) => {
|
||||
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
@@ -71,6 +86,7 @@ export function OpenClawFormFields({
|
||||
|
||||
// Add a new model entry
|
||||
const handleAddModel = () => {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
onModelsChange([
|
||||
...models,
|
||||
{
|
||||
@@ -85,6 +101,7 @@ export function OpenClawFormFields({
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (index: number) => {
|
||||
modelKeysRef.current.splice(index, 1);
|
||||
const newModels = [...models];
|
||||
newModels.splice(index, 1);
|
||||
onModelsChange(newModels);
|
||||
@@ -216,7 +233,7 @@ export function OpenClawFormFields({
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={modelKeys[index]}
|
||||
className="p-3 border border-border/50 rounded-lg space-y-3"
|
||||
>
|
||||
{/* Model ID and Name row */}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function useOpenClawEnv() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.env,
|
||||
queryFn: () => openclawApi.getEnv(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ export function useOpenClawTools() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.tools,
|
||||
queryFn: () => openclawApi.getTools(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ export function useOpenClawAgentsDefaults() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.agentsDefaults,
|
||||
queryFn: () => openclawApi.getAgentsDefaults(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -744,7 +744,31 @@
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only."
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
"apiProtocol": "API Protocol",
|
||||
"selectProtocol": "Select API Protocol",
|
||||
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
|
||||
"baseUrl": "API Endpoint",
|
||||
"baseUrlHint": "The provider's API endpoint address.",
|
||||
"models": "Models",
|
||||
"addModel": "Add Model",
|
||||
"noModels": "No models configured. Click Add Model to configure available models.",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "Display Name",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "Context Window",
|
||||
"maxTokens": "Max Output Tokens",
|
||||
"reasoning": "Reasoning Mode",
|
||||
"reasoningOn": "Enabled",
|
||||
"reasoningOff": "Disabled",
|
||||
"inputCost": "Input Cost ($/M tokens)",
|
||||
"outputCost": "Output Cost ($/M tokens)",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"cacheReadCost": "Cache Read Cost ($/M tokens)",
|
||||
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
|
||||
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
|
||||
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
@@ -1143,7 +1167,8 @@
|
||||
"add": "Add Variable",
|
||||
"saveSuccess": "Environment variables saved",
|
||||
"saveFailed": "Failed to save environment variables",
|
||||
"loadFailed": "Failed to load environment variables"
|
||||
"loadFailed": "Failed to load environment variables",
|
||||
"duplicateKey": "Duplicate variable name detected: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tool Permissions",
|
||||
|
||||
@@ -744,7 +744,31 @@
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。"
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
"apiProtocol": "API プロトコル",
|
||||
"selectProtocol": "API プロトコルを選択",
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
@@ -1143,7 +1167,8 @@
|
||||
"add": "変数を追加",
|
||||
"saveSuccess": "環境変数を保存しました",
|
||||
"saveFailed": "環境変数の保存に失敗しました",
|
||||
"loadFailed": "環境変数の読み込みに失敗しました"
|
||||
"loadFailed": "環境変数の読み込みに失敗しました",
|
||||
"duplicateKey": "重複する変数名が検出されました: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "ツール権限",
|
||||
|
||||
@@ -744,7 +744,31 @@
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符"
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"apiProtocol": "API 协议",
|
||||
"selectProtocol": "选择 API 协议",
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
@@ -1143,7 +1167,8 @@
|
||||
"add": "添加变量",
|
||||
"saveSuccess": "环境变量已保存",
|
||||
"saveFailed": "保存环境变量失败",
|
||||
"loadFailed": "读取环境变量失败"
|
||||
"loadFailed": "读取环境变量失败",
|
||||
"duplicateKey": "检测到重复的变量名: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具权限",
|
||||
|
||||
@@ -67,24 +67,6 @@ export const openclawApi = {
|
||||
return await invoke("set_openclaw_agents_defaults", { defaults });
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Provider Import
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Import providers from live config (openclaw.json) to database
|
||||
*/
|
||||
async importProvidersFromLive(): Promise<number> {
|
||||
return await invoke("import_openclaw_providers_from_live");
|
||||
},
|
||||
|
||||
/**
|
||||
* Get provider IDs that exist in live config (openclaw.json)
|
||||
*/
|
||||
async getLiveProviderIds(): Promise<string[]> {
|
||||
return await invoke("get_openclaw_live_provider_ids");
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Env Configuration
|
||||
// ============================================================
|
||||
|
||||
@@ -30,8 +30,10 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
id = generateUUID();
|
||||
}
|
||||
|
||||
const { providerKey: _providerKey, ...rest } = providerInput;
|
||||
|
||||
const newProvider: Provider = {
|
||||
...providerInput,
|
||||
...rest,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -224,6 +224,8 @@ export interface Settings {
|
||||
geminiConfigDir?: string;
|
||||
// 覆盖 OpenCode 配置目录(可选)
|
||||
opencodeConfigDir?: string;
|
||||
// 覆盖 OpenClaw 配置目录(可选)
|
||||
openclawConfigDir?: string;
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
// 当前 Claude 供应商 ID(优先于数据库 is_current)
|
||||
|
||||
Reference in New Issue
Block a user