feat(desktop): add plugin resource actions

This commit is contained in:
leokun
2026-09-05 00:12:14 +08:00
parent 8fbbcd5ae9
commit 2cb15b541b
24 changed files with 1110 additions and 254 deletions
@@ -3,7 +3,7 @@ import { api, pluginText, type PluginDescriptor, type PluginImportFile, type Plu
import { useI18n } from "../../i18n/store";
import { PageContent } from "../../shell/layout/PageContent";
import { appStore, useAppStore } from "../../shared/store/appStore";
import { ActionMenu } from "../../shared/ui/ActionMenu";
import { ActionMenu, type ActionMenuItem } from "../../shared/ui/ActionMenu";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { Modal } from "../../shared/ui/Modal";
@@ -170,6 +170,38 @@ function PluginCard({ plugin, onOpen }: {
}
};
const moreItems: ActionMenuItem[] = [
...(importResource
? [
{
id: "import",
label: importing ? t("正在导入…") : t("批量导入"),
disabled: importing,
onSelect: () => importInput.current?.click(),
},
]
: []),
...(exportResource
? [
{
id: "export",
label: t("批量导出"),
onSelect: () =>
void api.openExternalUrl(
api.pluginResourceExportUrl(
ports.service_port,
plugin.id,
exportResource.type,
),
),
},
]
: []),
...(plugin.version
? [{ id: "version", type: "text" as const, label: `v${plugin.version}` }]
: []),
];
return (
<Card className={styles.pluginCard}>
<div className={styles.pluginCardTop}>
@@ -191,9 +223,9 @@ function PluginCard({ plugin, onOpen }: {
models: modelCount,
})}
</span>
<span className={styles.pluginAuthor}>
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
</span>
{plugin.author && (
<span className={styles.pluginAuthor}>{plugin.author}</span>
)}
</div>
<div className={styles.cardActions}>
<TruncatedButton
@@ -209,39 +241,9 @@ function PluginCard({ plugin, onOpen }: {
onClick={() => onOpen(plugin.id, "settings")}
/>
)}
{(importResource || exportResource) && (
{moreItems.length > 0 && (
<span className={styles.moreAction}>
<ActionMenu
label={t("更多")}
items={[
...(importResource
? [
{
id: "import",
label: importing ? t("正在导入…") : t("批量导入"),
disabled: importing,
onSelect: () => importInput.current?.click(),
},
]
: []),
...(exportResource
? [
{
id: "export",
label: t("批量导出"),
onSelect: () =>
void api.openExternalUrl(
api.pluginResourceExportUrl(
ports.service_port,
plugin.id,
exportResource.type,
),
),
},
]
: []),
]}
/>
<ActionMenu label={t("更多")} items={moreItems} />
</span>
)}
{importResource && (
@@ -162,6 +162,37 @@
}
}
.actionDescription {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
.actionCardList {
display: flex;
flex-direction: column;
gap: 8px;
}
.actionCard {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
}
.actionCardMain {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.ready {
color: var(--vscode-testing-iconPassed, #73c991) !important;
}
@@ -6,6 +6,9 @@ import {
type PluginDescriptor,
type PluginOAuthBegin,
type PluginProviderDescriptor,
type PluginResourceAction,
type PluginResourceActionCard,
type PluginResourceActionResult,
type PluginResourceDescriptor,
type PluginResourceView,
} from "../../shared/api";
@@ -13,6 +16,7 @@ import { useI18n } from "../../i18n/store";
import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { Modal } from "../../shared/ui/Modal";
import { Switch } from "../../shared/ui/Switch";
@@ -145,6 +149,12 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [modelProviderId, setModelProviderId] = useState<string | null>(null);
const [resourceAction, setResourceAction] = useState<{
resource: PluginResourceDescriptor;
item: PluginResourceView;
} | null>(null);
const [resourceActionResult, setResourceActionResult] = useState<PluginResourceActionResult | null>(null);
const [resourceActionError, setResourceActionError] = useState<string | null>(null);
const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? null : null;
const run = async (key: string, task: () => Promise<void>) => {
@@ -160,6 +170,38 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
}
};
const executeResourceAction = async (
target: { resource: PluginResourceDescriptor; item: PluginResourceView },
action: PluginResourceAction,
input: unknown = {},
) => {
const key = `action:${target.item.id}:${action.id}`;
setBusy(key);
setResourceActionError(null);
try {
const result = await api.pluginResourceAction(
plugin.id,
target.resource.type,
target.item.id,
action.id,
input,
);
setResourceActionResult(result);
await appStore.refreshPlugins();
} catch (cause) {
setResourceActionError(errorText(cause));
} finally {
setBusy(null);
}
};
const openResourceAction = (resource: PluginResourceDescriptor, item: PluginResourceView, action: PluginResourceAction) => {
setResourceAction({ resource, item });
setResourceActionResult(null);
setResourceActionError(null);
void executeResourceAction({ resource, item }, action);
};
return <div className={styles.panel}>
{plugin.providers.map((provider) => <ProviderRow
key={provider.id}
@@ -175,6 +217,7 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
key={resource.type}
resource={resource}
busy={busy !== null}
onAction={(item, action) => openResourceAction(resource, item, action)}
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
await api.refreshPluginResource(plugin.id, resource.type, item.id);
})}
@@ -194,6 +237,15 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
}
})}
/>}
{resourceAction && <ResourceActionModal
action={resourceAction.resource.actions.find((item) => item.target === "resource") ?? null}
cardAction={resourceAction.resource.actions.find((item) => item.target === "card") ?? null}
result={resourceActionResult}
busy={busy !== null}
error={resourceActionError}
onClose={() => setResourceAction(null)}
onCardAction={(action, card) => void executeResourceAction(resourceAction, action, { cardId: card.id })}
/>}
</div>;
}
@@ -278,9 +330,10 @@ function ModelManagementModal({ provider, busy, onClose, onSubmit }: {
</Modal>;
}
function ResourceList({ resource, busy, onRefresh, onDelete }: {
function ResourceList({ resource, busy, onAction, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busy: boolean;
onAction: (item: PluginResourceView, action: PluginResourceAction) => void;
onRefresh: (item: PluginResourceView) => void;
onDelete: (item: PluginResourceView) => void;
}) {
@@ -305,8 +358,10 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
{visible.map((item) => <ResourceRow
key={item.id}
item={item}
actions={resource.actions.filter((action) => action.target === "resource")}
canRefresh={resource.canRefresh}
disabled={busy}
onAction={(action) => onAction(item, action)}
onRefresh={() => onRefresh(item)}
onDelete={() => onDelete(item)}
/>)}
@@ -321,10 +376,12 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
</FormField>;
}
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
function ResourceRow({ item, actions, canRefresh, disabled, onAction, onRefresh, onDelete }: {
item: PluginResourceView;
actions: PluginResourceAction[];
canRefresh: boolean;
disabled: boolean;
onAction: (action: PluginResourceAction) => void;
onRefresh: () => void;
onDelete: () => void;
}) {
@@ -341,12 +398,86 @@ function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
</div>
<div className={styles.actions}>
<StateBadge state={item.state} />
{actions.map((action) => <Button key={action.id} size="small" disabled={disabled} onClick={() => onAction(action)}>{pluginText(action.displayName, locale)}</Button>)}
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
</div>
</Card>;
}
function ResourceActionModal({ action, cardAction, result, busy, error, onClose, onCardAction }: {
action: PluginResourceAction | null;
cardAction: PluginResourceAction | null;
result: PluginResourceActionResult | null;
busy: boolean;
error: string | null;
onClose: () => void;
onCardAction: (action: PluginResourceAction, card: PluginResourceActionCard) => void;
}) {
const { locale } = useI18n();
const [pendingCard, setPendingCard] = useState<PluginResourceActionCard | null>(null);
const title = result ? pluginText(result.title, locale) : action ? pluginText(action.displayName, locale) : t("资源详情");
const cardActions = cardAction ? [cardAction] : [];
return <>
<Modal open title={title} busy={busy} onClose={onClose} submitLabel={t("关闭")} onSubmit={onClose}>
{result?.description && <span className={styles.actionDescription}>{pluginText(result.description, locale)}</span>}
{busy && <span className={styles.empty}>{t("正在加载…")}</span>}
{error && <span className={styles.error} role="alert">{error}</span>}
{!busy && !error && result && result.cards.length === 0 && <span className={styles.empty}>{t("没有可用的重置卡。")}</span>}
{!busy && !error && result && <div className={styles.actionCardList}>
{result.cards.map((card) => <Card key={card.id} className={styles.actionCard}>
<div className={styles.actionCardMain}>
<strong>{pluginText(card.title, locale)}</strong>
{card.status && <span>{formatActionStatus(card.status, locale)}</span>}
{card.grantedAtMs !== null && card.grantedAtMs !== undefined && <span>{t("发放时间:{time}", { time: formatActionDate(card.grantedAtMs, locale) })}</span>}
{card.expiresAtMs !== null && card.expiresAtMs !== undefined && <span>{t("到期时间:{time}", { time: formatActionDate(card.expiresAtMs, locale) })}</span>}
{card.fields.map((field) => <span key={field.id}>{pluginText(field.label, locale)}: {field.value}</span>)}
</div>
{cardActions.length > 0 && <div className={styles.actions}>
{cardActions.map((cardActionItem) => <Button
key={cardActionItem.id}
size="small"
disabled={busy || card.status !== "available"}
onClick={() => cardActionItem.destructive ? setPendingCard(card) : onCardAction(cardActionItem, card)}
>{pluginText(cardActionItem.displayName, locale)}</Button>)}
</div>}
</Card>)}
</div>}
</Modal>
{pendingCard && cardAction && <ConfirmDialog
open
title={t("使用重置卡")}
busy={busy}
confirmLabel={t("确认使用")}
onCancel={() => setPendingCard(null)}
onConfirm={() => {
const card = pendingCard;
setPendingCard(null);
onCardAction(cardAction, card);
}}
>
<p>{t("使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?")}</p>
<strong>{pluginText(pendingCard.title, locale)}</strong>
</ConfirmDialog>}
</>;
}
function formatActionStatus(status: PluginResourceActionCard["status"], locale: string) {
const value = typeof status === "string" ? status : pluginText(status, locale);
switch (value.toLowerCase()) {
case "available": return t("可用");
case "redeemed":
case "used": return t("已使用");
case "expired": return t("已过期");
default: return value;
}
}
function formatActionDate(value: number, locale: string) {
return new Date(value).toLocaleString(locale);
}
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
if (state.status === "cooling") {
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -51,11 +51,13 @@
"1c6926877b1bbfb2": "Total requests: {tokens}",
"1cb7e646ac883fb2": "Proxy port",
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
"1d19efc260a0a18f": "Use reset card",
"1d27f02ed278ebc9": "Configuration file",
"1f9b46e61efcccb5": "Filter: {label}, {summary}",
"202064bb84804852": "Leave blank to use the default.",
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language",
"21bd738e0d7191a7": "Loading…",
"22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23ae7a90b1b9816d": "Clear scope",
@@ -80,6 +82,7 @@
"2f6416a2c424856b": "Final request URL",
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f9daa828907b93f": "Delete",
"2fe0e3339ac4d1bb": "Expired",
"2fe5a8d0eee9f14c": "Invalid",
"303c30f301514250": "Search resources",
"3260348163d03b8e": "Leave blank to keep unchanged",
@@ -108,7 +111,6 @@
"402495402ce333b1": "Reinitialize plugins",
"40a08e7cf320ae07": "Clear detailed records?",
"4125fc7ba333524c": "Default light",
"4256064de039de89": "Display granularity",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Total duration",
@@ -132,7 +134,6 @@
"4d99c976beb8827e": "Ready",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom",
"4f0f45b0312ca4ba": "1 hour",
"51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
@@ -150,6 +151,7 @@
"59346e82b3dd2998": "TAB service address",
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service",
"5acb7d61688e3c4f": "Confirm use",
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
@@ -211,7 +213,6 @@
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
"844b8cc8dff7c1d8": "Default",
"859757ea792a73da": "30 minutes",
"864597982c308d72": "Silent start enabled",
"86de7c4ee8fa7689": "Sync models",
"8716e1344b0daddb": "Cursor official",
@@ -240,8 +241,10 @@
"954ec984cd4f49d1": "Syncing…",
"966498853d801a52": "TAB connection",
"96f7642963ca0dbf": "Enable Cursor takeover",
"9845c165151daee3": "Used",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9a84733cc9ab1706": "Resource details",
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
"9b9bc9cd7c76406f": "Open authorization page",
"9c41b3a9e12ac994": "Reasoning effort",
@@ -283,6 +286,7 @@
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.",
"aee88743413144a2": "Refresh",
"affb73206cfa035d": "Expires: {time}",
"b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b254ff315d861346": "Try initializing again",
@@ -350,10 +354,10 @@
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbaa1c565cb1f108": "15 minutes",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"de878020fe02f0e9": "Using this card consumes it immediately and cannot be undone. Continue?",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df1baa9f706d970b": "To add",
"df3d58c7d84b85f2": "Settings",
@@ -381,8 +385,8 @@
"e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
"e85373502c372776": "1 minute",
"e8b1268c1e3610f2": "Existing",
"e9d8d890d33584e8": "No reset cards available.",
"ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
@@ -393,6 +397,7 @@
"edc70de18c6da1a6": "Install local CA",
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"eec6bf2dad677b9f": "Granted: {time}",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4f0ead1116b5b62": "Enabled",
+10 -5
View File
@@ -51,11 +51,13 @@
"1c6926877b1bbfb2": "总请求:{tokens}",
"1cb7e646ac883fb2": "代理端口",
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
"1d19efc260a0a18f": "使用重置卡",
"1d27f02ed278ebc9": "配置文件",
"1f9b46e61efcccb5": "筛选项:{label},{summary}",
"202064bb84804852": "留空时使用默认值。",
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统",
"21bd738e0d7191a7": "正在加载…",
"22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23ae7a90b1b9816d": "清理范围",
@@ -80,6 +82,7 @@
"2f6416a2c424856b": "最终请求地址",
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f9daa828907b93f": "删除",
"2fe0e3339ac4d1bb": "已过期",
"2fe5a8d0eee9f14c": "已失效",
"303c30f301514250": "搜索资源",
"3260348163d03b8e": "留空保持不变",
@@ -108,7 +111,6 @@
"402495402ce333b1": "重新初始化插件",
"40a08e7cf320ae07": "确定要清理详细记录吗?",
"4125fc7ba333524c": "默认亮色",
"4256064de039de89": "显示粒度",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时",
@@ -132,7 +134,6 @@
"4d99c976beb8827e": "可用",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义",
"4f0f45b0312ca4ba": "1小时",
"51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
@@ -150,6 +151,7 @@
"59346e82b3dd2998": "TAB 服务地址",
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务",
"5acb7d61688e3c4f": "确认使用",
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
@@ -211,7 +213,6 @@
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
"844b8cc8dff7c1d8": "默认",
"859757ea792a73da": "30分钟",
"864597982c308d72": "已开启静默启动",
"86de7c4ee8fa7689": "同步模型",
"8716e1344b0daddb": "Cursor 官方",
@@ -240,8 +241,10 @@
"954ec984cd4f49d1": "正在同步…",
"966498853d801a52": "TAB 选择",
"96f7642963ca0dbf": "开启接管Cursor",
"9845c165151daee3": "已使用",
"9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA",
"9a84733cc9ab1706": "资源详情",
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
"9b9bc9cd7c76406f": "打开授权网页",
"9c41b3a9e12ac994": "思考强度",
@@ -283,6 +286,7 @@
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"aee88743413144a2": "刷新",
"affb73206cfa035d": "到期时间:{time}",
"b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b254ff315d861346": "请重试初始化",
@@ -350,10 +354,10 @@
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbaa1c565cb1f108": "15分钟",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"de878020fe02f0e9": "使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置",
@@ -381,8 +385,8 @@
"e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
"e85373502c372776": "1分钟",
"e8b1268c1e3610f2": "已存在",
"e9d8d890d33584e8": "没有可用的重置卡。",
"ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
@@ -393,6 +397,7 @@
"edc70de18c6da1a6": "安装本地 CA",
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"eec6bf2dad677b9f": "发放时间:{time}",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4f0ead1116b5b62": "启用",
+31
View File
@@ -227,6 +227,35 @@ export interface PluginImportDescriptor {
multiple: boolean;
}
export interface PluginResourceAction {
id: string;
displayName: PluginLocalizedText;
description: PluginLocalizedText | null;
target: "resource" | "card";
destructive: boolean;
}
export interface PluginResourceActionField {
id: string;
label: PluginLocalizedText;
value: string;
}
export interface PluginResourceActionCard {
id: string;
title: PluginLocalizedText;
status: PluginLocalizedText | null;
grantedAtMs: number | null;
expiresAtMs: number | null;
fields: PluginResourceActionField[];
}
export interface PluginResourceActionResult {
title: PluginLocalizedText;
description: PluginLocalizedText | null;
cards: PluginResourceActionCard[];
}
export interface PluginResourceDescriptor {
type: string;
displayName: PluginLocalizedText;
@@ -234,6 +263,7 @@ export interface PluginResourceDescriptor {
import: PluginImportDescriptor | null;
canRefresh: boolean;
canRemove: boolean;
actions: PluginResourceAction[];
resources: PluginResourceView[];
}
@@ -473,6 +503,7 @@ export const api = {
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
pluginResourceAction: (pluginId: string, resourceType: string, resourceId: string, actionId: string, input: unknown = {}) => request<PluginResourceActionResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionId)}`, { method: "POST", body: JSON.stringify(input) }),
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
setPluginModelEnabled: (pluginId: string, providerId: string, modelId: string, enabled: boolean) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }),
@@ -11,18 +11,22 @@
border-radius: 6px;
box-shadow: var(--oa-dropdown-shadow);
button {
button,
.textItem {
width: 100%;
min-height: 30px;
display: block;
padding: 5px 8px;
color: var(--vscode-dropdown-foreground);
text-align: left;
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
}
button {
color: var(--vscode-dropdown-foreground);
background: transparent;
&:hover:not(:disabled) {
background: var(--vscode-list-activeSelectionBackground);
@@ -34,6 +38,11 @@
cursor: not-allowed;
}
}
.textItem {
color: var(--vscode-descriptionForeground);
cursor: default;
}
}
.openIcon {
+31 -18
View File
@@ -6,12 +6,19 @@ import { Icon } from "./Icon";
import { chevronDownIcon } from "./icons";
import styles from "./ActionMenu.module.scss";
export type ActionMenuItem = {
id: string;
label: string;
disabled?: boolean;
onSelect: () => void;
};
export type ActionMenuItem =
| {
id: string;
label: string;
type?: "action";
disabled?: boolean;
onSelect: () => void;
}
| {
id: string;
label: string;
type: "text";
};
/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */
export function ActionMenu({ label, items, disabled }: {
@@ -80,18 +87,24 @@ export function ActionMenu({ label, items, disabled }: {
}
}}
>
{items.map((item) => <button
key={item.id}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>)}
{items.map((item) => item.type === "text" ? (
<span key={item.id} className={styles.textItem}>
{item.label}
</span>
) : (
<button
key={item.id}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
)}