feat: simplify agent permissions and require current ZIP backups
This commit is contained in:
1 parent
efc2c7c734
commit
91c489513a
51 files changed
+1680
-1270
No files matched your search
+527
-242
@@ -5,14 +5,14 @@ type Connection = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
clientId: string | null;
|
||||
};
|
||||
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
|
||||
type Management = {
|
||||
capabilities: { name: string; description: string; scope: string; destructive?: boolean }[];
|
||||
mcpUrl: string;
|
||||
mode: string;
|
||||
grants: Connection[];
|
||||
operations: Operation[];
|
||||
calls: { id: string; tool: string; status: string; createdAt: string }[];
|
||||
@@ -23,8 +23,6 @@ type Preview = {
|
||||
status: string;
|
||||
description: string;
|
||||
impact: unknown;
|
||||
web?: string;
|
||||
sensitive: boolean;
|
||||
result?: unknown;
|
||||
};
|
||||
type Consent = {
|
||||
@@ -34,6 +32,72 @@ type Consent = {
|
||||
redirectUri: string;
|
||||
resource: string;
|
||||
};
|
||||
const scopeDetails = [
|
||||
{
|
||||
id: 'read',
|
||||
title: '只读查询',
|
||||
summary: '查看账目和状态,不修改金额',
|
||||
detail:
|
||||
'查询账户、资产、债务、关联、余额和历史、净资产趋势、日历、计划与执行记录、汇率、贵金属报价、设置和图标;读取本连接操作及文件状态。隐藏账户默认不允许读取,须额外授权。',
|
||||
},
|
||||
{
|
||||
id: 'draft',
|
||||
title: '创建草稿',
|
||||
summary: '提出普通修改,等待你在网页确认',
|
||||
detail:
|
||||
'可提出创建或编辑持仓、余额/估值更新、转账和借贷还款、历史更正/删除、计划管理、分组排序、设置修改及私有图标上传等普通操作。草稿明确保存参数和影响,10 分钟过期;不会直接入账。',
|
||||
},
|
||||
{
|
||||
id: 'write',
|
||||
title: '普通写入',
|
||||
summary: '普通修改直接执行',
|
||||
detail:
|
||||
'覆盖普通修改和创建草稿的操作。授予 write 后普通修改直接执行;draft 等待网页确认;read 拒绝修改。归档、历史删除和转账撤销也属于普通写入,请按所需范围授权。',
|
||||
},
|
||||
];
|
||||
const durationOptions = [
|
||||
['1', '1 天'],
|
||||
['3', '3 天'],
|
||||
['7', '7 天'],
|
||||
['30', '30 天'],
|
||||
['365', '1 年(365 天)'],
|
||||
['permanent', '永久(可撤销)'],
|
||||
];
|
||||
const capabilityGroups = [
|
||||
{
|
||||
title: '账户、资产与债务',
|
||||
pattern: /^(positions_|position_|balance_|metal_holding|metal_configure|metal_value)/,
|
||||
summary: '创建和管理持仓、分组、图标、归档与计入开关;贵金属按克数管理。',
|
||||
},
|
||||
{
|
||||
title: '资金变化与历史',
|
||||
pattern: /^(transfer|loan|debt|history|revision)/,
|
||||
summary: '转账、借入借出、收款还款、记录更正、删除与撤销,沿用金额重算规则。',
|
||||
},
|
||||
{
|
||||
title: '统计、汇率与计划',
|
||||
pattern: /^(overview|trend|calendar|schedule|rates|metals_)/,
|
||||
summary: '净资产与趋势、日历筛选、定时计划及执行记录、汇率和参考报价。',
|
||||
},
|
||||
{
|
||||
title: '设置与私有图标',
|
||||
pattern: /^(settings|icons|icon_|backup|import|data_|credentials|hidden|file_)/,
|
||||
summary: '非安全类用户设置、私有图库上传与读取;敏感操作仅在网站执行。',
|
||||
},
|
||||
];
|
||||
function instructions(url: string) {
|
||||
return [
|
||||
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
|
||||
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;支持自定义 Bearer 头的客户端可在安全的凭据设置中填写个人令牌。不要让我把密码或令牌粘贴到对话中。',
|
||||
'先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。',
|
||||
'查询:用 positions_list 搜索对象;遇到同名先让我选择稳定 ID;按 limit/offset 或 cursor 翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。',
|
||||
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
|
||||
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
|
||||
'MCP 不提供密码修改、清空数据、备份导出恢复、共享图标发布及汇率或报价修改;这些流程由我在网站操作。',
|
||||
'我接下来会告诉你具体任务;在我提出任务前先不要修改数据。未发现的工具或未授予的权限请如实告知,不要猜测成功。',
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function AgentConnections() {
|
||||
const [data, setData] = useState<Management | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
@@ -41,7 +105,14 @@ export function AgentConnections() {
|
||||
[token, setToken] = useState(''),
|
||||
[preview, setPreview] = useState<Preview | null>(null),
|
||||
[consent, setConsent] = useState<Consent | null>(null),
|
||||
[busy, setBusy] = useState(false);
|
||||
[busy, setBusy] = useState(false),
|
||||
[tokenPermission, setTokenPermission] = useState('read'),
|
||||
[tokenHiddenRead, setTokenHiddenRead] = useState(false),
|
||||
[tokenHiddenWrite, setTokenHiddenWrite] = useState(false),
|
||||
[agentView, setAgentView] = useState('connect'),
|
||||
[consentLevel, setConsentLevel] = useState('read'),
|
||||
[consentHiddenRead, setConsentHiddenRead] = useState(false),
|
||||
[consentHiddenWrite, setConsentHiddenWrite] = useState(false);
|
||||
const load = async () => setData(await api<Management>('/agent'));
|
||||
const act = async (work: () => Promise<unknown>) => {
|
||||
if (busy) return;
|
||||
@@ -68,216 +139,417 @@ export function AgentConnections() {
|
||||
if (operation) await show(operation);
|
||||
});
|
||||
}, []);
|
||||
const copy = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setMessage(label);
|
||||
} catch {
|
||||
setError('复制失败,请从下方可选中文本手动复制');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section className="panel agent-panel">
|
||||
<h2>连接 Agent</h2>
|
||||
<div className="agent-heading">
|
||||
<div>
|
||||
<h2>连接 Agent</h2>
|
||||
<p className="muted">管理接入、操作权限和需要你确认的修改。</p>
|
||||
</div>
|
||||
{data && <span className="badge">{data.capabilities.length} 个工具 · 按连接授权</span>}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="danger-text">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
{message && (
|
||||
<p role="status" className="agent-notice">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<p>远程 MCP 地址</p>
|
||||
<code className="agent-address">{data.mcpUrl}</code>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard.writeText(data.mcpUrl)}
|
||||
>
|
||||
复制地址
|
||||
</button>
|
||||
<p className="muted">
|
||||
支持 Streamable HTTP。OAuth 客户端使用此地址发现授权信息,浏览器登录 WorthPath
|
||||
后审核连接名称、回调地址和权限。访问令牌每小时过期,刷新令牌最多 30
|
||||
天并在使用时轮换。个人令牌适用于支持 Bearer 头的客户端。
|
||||
</p>
|
||||
<p className="muted">
|
||||
已验证客户端:官方 TypeScript SDK 1.31.0(OAuth / Bearer)。其他 Agent
|
||||
尚未验证;不会保证任意客户端兼容。
|
||||
</p>
|
||||
<details>
|
||||
<summary>官方 SDK 的已验证接入配置</summary>
|
||||
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(
|
||||
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
|
||||
<div className="agent-layout">
|
||||
<nav className="agent-tabs agent-wide" aria-label="Agent 功能分区">
|
||||
{[
|
||||
['connect', '开始连接'],
|
||||
['tools', '权限与工具'],
|
||||
['connections', '连接管理'],
|
||||
['operations', '操作记录'],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={agentView === value ? 'primary' : 'secondary'}
|
||||
aria-pressed={agentView === value}
|
||||
onClick={() => setAgentView(value)}
|
||||
>
|
||||
{label}
|
||||
{value === 'operations' &&
|
||||
data.operations.some(
|
||||
(o) => o.status === 'pending' && new Date(o.expiresAt) > new Date(),
|
||||
)
|
||||
}
|
||||
>
|
||||
复制本项目诊断客户端命令
|
||||
</button>
|
||||
<p className="muted">
|
||||
先在终端设置 MCP_ACCESS_TOKEN,再运行复制的命令。完整 OAuth 示例及安全存储说明见
|
||||
docs/mcp.md;此配置仅针对官方 SDK 1.31.0。
|
||||
</p>
|
||||
</details>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget),
|
||||
form = e.currentTarget;
|
||||
void act(async () => {
|
||||
await api('/agent/policy', 'PUT', {
|
||||
mode: f.get('mode'),
|
||||
password: f.get('password'),
|
||||
});
|
||||
form.reset();
|
||||
setMessage('写入策略已保存');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h3>写入策略</h3>
|
||||
<select name="mode" defaultValue={data.mode} key={data.mode}>
|
||||
<option value="readonly">只读</option>
|
||||
<option value="draft">创建草稿,由网页确认</option>
|
||||
<option value="direct">已授权 write 的连接可直接普通写入</option>
|
||||
</select>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="当前密码"
|
||||
/>
|
||||
<button className="secondary" disabled={busy}>
|
||||
保存策略
|
||||
</button>
|
||||
</form>
|
||||
<h3>OAuth 授权与个人令牌</h3>
|
||||
<p className="muted">
|
||||
read 查询;draft 创建草稿;write 按写入策略执行普通写入;sensitive
|
||||
发起敏感操作,仍须网页验证密码。令牌到期可新建并撤销旧令牌。
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget,
|
||||
f = new FormData(form);
|
||||
void act(async () => {
|
||||
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
|
||||
name: f.get('name'),
|
||||
days: Number(f.get('days')),
|
||||
password: f.get('password'),
|
||||
scopes: f.getAll('scope'),
|
||||
});
|
||||
setToken(v.token);
|
||||
form.reset();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input name="name" required maxLength={100} placeholder="连接名称" />
|
||||
<input
|
||||
name="days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
defaultValue={30}
|
||||
required
|
||||
aria-label="有效天数"
|
||||
/>
|
||||
{['read', 'draft', 'write', 'sensitive'].map((s) => (
|
||||
<label key={s}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="scope"
|
||||
value={s}
|
||||
defaultChecked={s === 'read'}
|
||||
required={s === 'read'}
|
||||
/>
|
||||
{s}
|
||||
</label>
|
||||
? ' · 待确认'
|
||||
: ''}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="当前密码"
|
||||
/>
|
||||
<button className="secondary" disabled={busy}>
|
||||
创建个人令牌
|
||||
</button>
|
||||
</form>
|
||||
{token && (
|
||||
<div role="status">
|
||||
<p>完整令牌仅显示这一次,请妥善保存。</p>
|
||||
<code className="agent-address">{token}</code>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard.writeText(token)}
|
||||
>
|
||||
复制令牌
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={() => setToken('')}>
|
||||
隐藏令牌
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{agentView === 'connect' && (
|
||||
<>
|
||||
<section className="agent-card agent-wide">
|
||||
<h3>开始连接</h3>
|
||||
<p>远程 MCP 地址</p>
|
||||
<div className="agent-copy-row">
|
||||
<code className="agent-address">{data.mcpUrl}</code>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => void copy(data.mcpUrl, 'MCP 地址已复制')}
|
||||
>
|
||||
复制地址
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">在客户端添加地址,完成网页授权,再复制教程并描述任务。</p>
|
||||
<details className="agent-details">
|
||||
<summary>查看连接步骤</summary>
|
||||
<ol className="agent-steps">
|
||||
<li>
|
||||
<strong>配置连接</strong>:在支持远程 MCP OAuth 的客户端中填写地址,选择
|
||||
Streamable HTTP;在 WorthPath 网页登录后审核名称、回调和权限。
|
||||
</li>
|
||||
<li>
|
||||
<strong>选择连接权限</strong>:先从 read
|
||||
或草稿模式开始,根据需要增加权限。OAuth 访问令牌 1 小时,刷新授权最多 30
|
||||
天并轮换。
|
||||
</li>
|
||||
<li>
|
||||
<strong>把操作说明交给 Agent</strong>
|
||||
:复制下方教程,不包含任何密码或令牌,再描述具体任务。
|
||||
</li>
|
||||
<li>
|
||||
<strong>确认并查看结果</strong>:草稿在网页确认,Agent
|
||||
可查询完成状态。随时在“已授权连接”撤销。
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
<div className="agent-copy-row">
|
||||
<h4>可直接复制给 Agent 的教程</h4>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() => void copy(instructions(data.mcpUrl), 'Agent 使用教程已复制')}
|
||||
>
|
||||
复制 Agent 使用教程
|
||||
</button>
|
||||
</div>
|
||||
<details className="agent-details">
|
||||
<summary>预览 Agent 使用教程</summary>
|
||||
<textarea
|
||||
className="agent-tutorial"
|
||||
aria-label="Agent 使用教程"
|
||||
readOnly
|
||||
value={instructions(data.mcpUrl)}
|
||||
rows={8}
|
||||
/>
|
||||
</details>
|
||||
<details className="agent-details">
|
||||
<summary>支持 Bearer 头的客户端与已验证 SDK 配置</summary>
|
||||
<p>
|
||||
在下方创建个人令牌,把完整值填入客户端的安全凭据设置;请求头为 Authorization:
|
||||
Bearer <令牌>。个人令牌只显示一次,不能作为通用 OAuth 的替代。
|
||||
</p>
|
||||
<pre>{`new StreamableHTTPClientTransport(new URL(${JSON.stringify(data.mcpUrl)}), {\n requestInit: { headers: { Authorization: 'Bearer ' + process.env.MCP_ACCESS_TOKEN } }\n});`}</pre>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void copy(
|
||||
`$env:MCP_SERVER_URL=${JSON.stringify(data.mcpUrl)}\npnpm --filter @worthpath/api mcp:probe`,
|
||||
'诊断命令已复制',
|
||||
)
|
||||
}
|
||||
>
|
||||
复制诊断客户端命令
|
||||
</button>
|
||||
<p className="muted">
|
||||
先安全设置 MCP_ACCESS_TOKEN。实际验证客户端:官方 TypeScript SDK 1.31.0(OAuth /
|
||||
Bearer);其他 Agent 尚未验证,不提供未经验证的产品配置格式。
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<h3>已授权连接(最近 100 条)</h3>
|
||||
{data.grants.map((g) => (
|
||||
<div key={g.id} className="agent-row">
|
||||
<span>
|
||||
{g.name} · {g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
|
||||
{new Date(g.expiresAt).toLocaleString()} · {g.revokedAt ? '已撤销' : ''}
|
||||
</span>
|
||||
{!g.revokedAt && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + g.id, 'DELETE');
|
||||
setMessage('连接已撤销');
|
||||
})
|
||||
}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<h3>待确认及最近操作(最近 100 条)</h3>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void act(load)}
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
{data.operations.map((o) => (
|
||||
<div key={o.id} className="agent-row">
|
||||
<span>
|
||||
{o.tool} ·{' '}
|
||||
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
|
||||
? 'expired'
|
||||
: o.status}{' '}
|
||||
· {new Date(o.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => show(o.id))}
|
||||
>
|
||||
查看影响和结果
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<h3>最近调用(不保存参数和敏感内容)</h3>
|
||||
{data.calls.map((c) => (
|
||||
<div key={c.id} className="agent-row">
|
||||
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
{agentView === 'tools' && (
|
||||
<>
|
||||
<section className="agent-card agent-wide">
|
||||
<h3>Agent 可以做什么</h3>
|
||||
<p className="muted">
|
||||
这里列出服务实际提供的工具。能否执行还取决于连接权限、隐藏账户授权、项目归属和草稿确认。
|
||||
</p>
|
||||
<div className="agent-capabilities">
|
||||
{capabilityGroups.map((g) => (
|
||||
<details className="agent-details" key={g.title}>
|
||||
<summary>{g.title}</summary>
|
||||
<p>{g.summary}</p>
|
||||
<ul>
|
||||
{data.capabilities
|
||||
.filter((t) => g.pattern.test(t.name))
|
||||
.map((t) => (
|
||||
<li key={t.name}>
|
||||
<code>{t.name}</code> · {t.scope}
|
||||
{t.destructive ? ' · 会删除数据' : ''}
|
||||
<p>{t.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<details className="agent-details">
|
||||
<summary>查看全部 {data.capabilities.length} 个工具(含连接与状态工具)</summary>
|
||||
<ul>
|
||||
{data.capabilities.map((t) => (
|
||||
<li key={t.name}>
|
||||
<code>{t.name}</code> · {t.scope}
|
||||
<p>{t.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
<section className="agent-card agent-wide">
|
||||
<details className="agent-details">
|
||||
<summary>权限说明:read / draft / write</summary>
|
||||
<p>选择一种连接权限:只读、草稿修改或直接写入。隐藏账户可单独授权读取和修改。</p>
|
||||
<div className="agent-scope-grid">
|
||||
{scopeDetails.map((s) => (
|
||||
<article key={s.id}>
|
||||
<h4>
|
||||
{s.id} · {s.title}
|
||||
</h4>
|
||||
<strong>{s.summary}</strong>
|
||||
<p>{s.detail}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{agentView === 'connections' && (
|
||||
<>
|
||||
<section className="agent-card">
|
||||
<details className="agent-details">
|
||||
<summary>创建个人访问令牌(可选)</summary>
|
||||
<p className="muted">
|
||||
适用于支持 Bearer
|
||||
头的客户端。固定期限到期后新建并撤销旧令牌;永久令牌不会自动过期,仍可撤销。
|
||||
</p>
|
||||
<form
|
||||
className="agent-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget,
|
||||
f = new FormData(form);
|
||||
void act(async () => {
|
||||
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
|
||||
name: f.get('name'),
|
||||
days: f.get('days') === 'permanent' ? null : Number(f.get('days')),
|
||||
password: f.get('password'),
|
||||
scopes: [
|
||||
'read',
|
||||
...(f.get('permission') !== 'read'
|
||||
? [String(f.get('permission'))]
|
||||
: []),
|
||||
...(f.get('hiddenRead') ? ['hidden_read'] : []),
|
||||
...(f.get('hiddenWrite') ? ['hidden_write'] : []),
|
||||
],
|
||||
});
|
||||
setToken(v.token);
|
||||
form.reset();
|
||||
setTokenPermission('read');
|
||||
setTokenHiddenRead(false);
|
||||
setTokenHiddenWrite(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
连接名称
|
||||
<input name="name" required maxLength={100} placeholder="例如:桌面助手" />
|
||||
</label>
|
||||
<label>
|
||||
令牌有效期
|
||||
<select name="days" defaultValue="30">
|
||||
{durationOptions.map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
连接权限
|
||||
<select
|
||||
name="permission"
|
||||
value={tokenPermission}
|
||||
onChange={(e) => {
|
||||
setTokenPermission(e.target.value);
|
||||
if (e.target.value === 'read') setTokenHiddenWrite(false);
|
||||
}}
|
||||
>
|
||||
<option value="read">只读</option>
|
||||
<option value="draft">草稿修改(网页确认)</option>
|
||||
<option value="write">直接写入</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="agent-scope-options">
|
||||
<legend>隐藏账户权限(默认关闭)</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="hiddenRead"
|
||||
checked={tokenHiddenRead}
|
||||
onChange={(e) => {
|
||||
setTokenHiddenRead(e.target.checked);
|
||||
if (!e.target.checked) setTokenHiddenWrite(false);
|
||||
}}
|
||||
/>
|
||||
允许读取隐藏账户
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="hiddenWrite"
|
||||
disabled={!tokenHiddenRead || tokenPermission === 'read'}
|
||||
checked={tokenHiddenWrite}
|
||||
onChange={(e) => setTokenHiddenWrite(e.target.checked)}
|
||||
/>
|
||||
允许修改隐藏账户(同时勾选读取,且选择草稿或直接写入)
|
||||
</label>
|
||||
</fieldset>
|
||||
<label>
|
||||
验证当前密码
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<button className="secondary" disabled={busy}>
|
||||
创建个人令牌
|
||||
</button>
|
||||
</form>
|
||||
{token && (
|
||||
<div role="status" className="agent-secret">
|
||||
<p>完整令牌仅显示这一次,请妥善保存到客户端凭据设置。</p>
|
||||
<code className="agent-address">{token}</code>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => void copy(token, '令牌已复制')}
|
||||
>
|
||||
复制令牌
|
||||
</button>
|
||||
<button className="secondary" type="button" onClick={() => setToken('')}>
|
||||
隐藏令牌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
</section>
|
||||
<section className="agent-card agent-wide">
|
||||
<div className="agent-copy-row">
|
||||
<h3>已授权连接(最近 100 条)</h3>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void act(load)}
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
{!data.grants.length && (
|
||||
<p className="muted">尚未授权连接。通过 OAuth 接入,或创建个人令牌。</p>
|
||||
)}
|
||||
{data.grants.map((g) => (
|
||||
<div key={g.id} className="agent-row">
|
||||
<div>
|
||||
<strong>{g.name}</strong>
|
||||
<p>
|
||||
{g.clientId ? 'OAuth' : '个人令牌'} · {g.scopes.join(', ')} ·{' '}
|
||||
{g.revokedAt
|
||||
? '已撤销'
|
||||
: g.expiresAt && new Date(g.expiresAt) <= new Date()
|
||||
? '已过期'
|
||||
: '有效'}
|
||||
</p>
|
||||
<small>
|
||||
{g.expiresAt
|
||||
? '到期:' + new Date(g.expiresAt).toLocaleString()
|
||||
: '永久 · 不会自动过期'}
|
||||
</small>
|
||||
</div>
|
||||
{!g.revokedAt && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + g.id, 'DELETE');
|
||||
setMessage('连接已撤销');
|
||||
})
|
||||
}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{agentView === 'operations' && (
|
||||
<>
|
||||
<section className="agent-card agent-wide">
|
||||
<h3>待确认及最近操作</h3>
|
||||
{!data.operations.length && (
|
||||
<p className="muted">暂无操作。Agent 发起的草稿会出现在这里。</p>
|
||||
)}
|
||||
{data.operations.map((o) => (
|
||||
<div key={o.id} className="agent-row">
|
||||
<span>
|
||||
{o.tool} ·{' '}
|
||||
{o.status === 'pending' && new Date(o.expiresAt) < new Date()
|
||||
? 'expired'
|
||||
: o.status}{' '}
|
||||
· {new Date(o.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => show(o.id))}
|
||||
>
|
||||
查看影响和结果
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<details className="agent-details">
|
||||
<summary>最近调用 · 不保存参数、密码或令牌</summary>
|
||||
{!data.calls.length && <p className="muted">暂无调用记录。</p>}
|
||||
{data.calls.map((c) => (
|
||||
<div key={c.id} className="agent-row">
|
||||
{c.tool} · {c.status} · {new Date(c.createdAt).toLocaleString()}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{consent && (
|
||||
<div className="agent-confirm">
|
||||
@@ -287,6 +559,46 @@ export function AgentConnections() {
|
||||
<p>资源:{consent.resource}</p>
|
||||
<p>回调地址:{consent.redirectUri}</p>
|
||||
<p className="muted">只批准你正在连接的客户端;请核对回调地址。</p>
|
||||
<label>
|
||||
授予此连接的权限
|
||||
<select
|
||||
value={consentLevel}
|
||||
onChange={(e) => {
|
||||
setConsentLevel(e.target.value);
|
||||
if (e.target.value === 'read') setConsentHiddenWrite(false);
|
||||
}}
|
||||
>
|
||||
<option value="read">只读</option>
|
||||
{consent.scopes.includes('draft') && (
|
||||
<option value="draft">草稿修改(网页确认)</option>
|
||||
)}
|
||||
{consent.scopes.includes('write') && <option value="write">直接写入</option>}
|
||||
</select>
|
||||
</label>
|
||||
{consent.scopes.includes('hidden_read') && (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consentHiddenRead}
|
||||
onChange={(e) => {
|
||||
setConsentHiddenRead(e.target.checked);
|
||||
if (!e.target.checked) setConsentHiddenWrite(false);
|
||||
}}
|
||||
/>
|
||||
允许读取隐藏账户
|
||||
</label>
|
||||
)}
|
||||
{consent.scopes.includes('hidden_write') && (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!consentHiddenRead || consentLevel === 'read'}
|
||||
checked={consentHiddenWrite}
|
||||
onChange={(e) => setConsentHiddenWrite(e.target.checked)}
|
||||
/>
|
||||
允许修改隐藏账户
|
||||
</label>
|
||||
)}
|
||||
{[true, false].map((approve) => (
|
||||
<button
|
||||
className="secondary"
|
||||
@@ -298,7 +610,16 @@ export function AgentConnections() {
|
||||
const v = await api<{ redirect: string }>(
|
||||
'/agent/authorizations/' + consent.id,
|
||||
'POST',
|
||||
{ approve },
|
||||
{
|
||||
approve,
|
||||
scopes: consent.scopes.filter(
|
||||
(s) =>
|
||||
s === 'read' ||
|
||||
s === consentLevel ||
|
||||
(s === 'hidden_read' && consentHiddenRead) ||
|
||||
(s === 'hidden_write' && consentHiddenWrite),
|
||||
),
|
||||
},
|
||||
);
|
||||
location.assign(v.redirect);
|
||||
})
|
||||
@@ -321,15 +642,10 @@ export function AgentConnections() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget),
|
||||
form = e.currentTarget;
|
||||
const form = e.currentTarget;
|
||||
void act(async () => {
|
||||
await api('/agent/operations/' + preview.operationId, 'POST', {
|
||||
approve: true,
|
||||
...(f.get('password') ? { password: f.get('password') } : {}),
|
||||
...(f.get('username') ? { username: f.get('username') } : {}),
|
||||
...(f.get('newPassword') ? { newPassword: f.get('newPassword') } : {}),
|
||||
...(f.get('confirmation') ? { confirmation: f.get('confirmation') } : {}),
|
||||
});
|
||||
form.reset();
|
||||
await show(preview.operationId);
|
||||
@@ -337,37 +653,6 @@ export function AgentConnections() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{preview.web === 'clear' && (
|
||||
<>
|
||||
<a href="/api/backup" download>
|
||||
先下载当前账号备份
|
||||
</a>
|
||||
<p>确认备份已保存后输入“确定清空”。</p>
|
||||
<input name="confirmation" required placeholder="确定清空" />
|
||||
</>
|
||||
)}
|
||||
{preview.web === 'credentials' && (
|
||||
<>
|
||||
<input name="username" placeholder="新账号(可选)" autoComplete="username" />
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
minLength={10}
|
||||
maxLength={72}
|
||||
placeholder="新密码(可选)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{preview.sensitive && (
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="当前密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
)}
|
||||
<button className="secondary" disabled={busy}>
|
||||
确认执行上述操作
|
||||
</button>
|
||||
|
||||
@@ -1538,7 +1538,6 @@ export default function App() {
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
<h3>{p.name}</h3>
|
||||
{p.groupName && <span className="badge">{p.groupName}</span>}
|
||||
{(p.included === false ||
|
||||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
||||
<span className="badge">{tr('不计入总览')}</span>
|
||||
@@ -2268,7 +2267,7 @@ export default function App() {
|
||||
{tr('选择备份并验证')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip,.json,application/json"
|
||||
accept=".zip,application/zip"
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
setBackup(null);
|
||||
@@ -2685,7 +2684,6 @@ export default function App() {
|
||||
date: v.date,
|
||||
metalType: v.metalType,
|
||||
metalGrams: v.metalGrams,
|
||||
metalPurity: v.metalPurity,
|
||||
metalCostPerGram: v.metalCostPerGram || null,
|
||||
autoValuation: true,
|
||||
hidden: f.has('hidden'),
|
||||
@@ -2866,9 +2864,6 @@ export default function App() {
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={tr('纯度(0–1,例如 0.999)')}>
|
||||
<input name="metalPurity" required defaultValue="1" inputMode="decimal" />
|
||||
</Field>
|
||||
<Field label={tr('买入价(每克,选填)')}>
|
||||
<input
|
||||
name="metalCostPerGram"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, money, type Position, currencies } from './api';
|
||||
import { api, money, type Position } from './api';
|
||||
import { t as tr } from './i18n';
|
||||
import { useToast } from './Toast';
|
||||
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
|
||||
@@ -87,7 +87,7 @@ export function MetalPanel({
|
||||
<h2>{tr('贵金属估价')}</h2>
|
||||
<p className="muted">
|
||||
{tr(
|
||||
'黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
|
||||
'黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
|
||||
)}
|
||||
</p>
|
||||
{position && (
|
||||
@@ -96,7 +96,6 @@ export function MetalPanel({
|
||||
position.id +
|
||||
String(position.metalGrams) +
|
||||
String(position.autoValuation) +
|
||||
String(position.metalPurity) +
|
||||
String(position.metalType) +
|
||||
String(position.metalCostPerGram)
|
||||
}
|
||||
@@ -109,7 +108,6 @@ export function MetalPanel({
|
||||
metalType: f.get('metalType'),
|
||||
metalGrams: f.get('metalGrams'),
|
||||
metalCostPerGram: f.get('metalCostPerGram') || null,
|
||||
metalPurity: f.get('metalPurity'),
|
||||
autoValuation: f.has('autoValuation'),
|
||||
}),
|
||||
'贵金属估价设置已保存',
|
||||
@@ -133,18 +131,6 @@ export function MetalPanel({
|
||||
defaultValue={position.metalGrams || ''}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('纯度(0–1,例如 0.999)')}
|
||||
<input
|
||||
name="metalPurity"
|
||||
required
|
||||
type="number"
|
||||
min="0.00000001"
|
||||
max="1"
|
||||
step="0.00000001"
|
||||
defaultValue={position.metalPurity || '1'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('买入价(每克,选填)')}({position.currency})
|
||||
<input
|
||||
@@ -155,7 +141,7 @@ export function MetalPanel({
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
{tr('买入成本按实物总克数计算;参考估值按克数和纯度计算。清空买入价后不显示盈亏。')}
|
||||
{tr('买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。')}
|
||||
</p>
|
||||
{position.metalCost != null && (
|
||||
<p>
|
||||
@@ -205,57 +191,6 @@ export function MetalPanel({
|
||||
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
|
||||
{tr(data?.status.message || '尚未尝试更新')}
|
||||
</p>
|
||||
<details>
|
||||
<summary>{tr('手动录入贵金属价格')}</summary>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
void act(
|
||||
() => api('/metals/prices', 'POST', Object.fromEntries(f)),
|
||||
'贵金属价格已保存',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{tr('贵金属品种')}
|
||||
<select name="metalType" defaultValue={position?.metalType || 'gold'}>
|
||||
<option value="gold">{tr('黄金')}</option>
|
||||
<option value="silver">{tr('白银')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr('币种')}
|
||||
<select name="currency" defaultValue={position?.currency || 'CNY'}>
|
||||
{currencies.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr('每克纯金属价格')}
|
||||
<input
|
||||
name="price"
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('报价日期')}
|
||||
<input
|
||||
name="date"
|
||||
type="date"
|
||||
required
|
||||
max={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
||||
defaultValue={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr('保存参考价')}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
{!position && (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
|
||||
@@ -558,5 +558,7 @@
|
||||
"估值盈亏": "Valuation profit/loss",
|
||||
"待估值": "Awaiting valuation",
|
||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
|
||||
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete."
|
||||
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete.",
|
||||
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Gold and silver are valued by weight and the reference price per gram. Daily updates retain prior prices on failure; fees and resale discounts are excluded.",
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss."
|
||||
}
|
||||
@@ -558,5 +558,7 @@
|
||||
"估值盈亏": "估值盈虧",
|
||||
"待估值": "待估值",
|
||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
|
||||
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。"
|
||||
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。",
|
||||
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。"
|
||||
}
|
||||
@@ -1880,3 +1880,158 @@ textarea,
|
||||
.agent-panel form > label input[type='checkbox'] {
|
||||
width: auto;
|
||||
}
|
||||
.agent-heading,
|
||||
.agent-copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.agent-heading h2,
|
||||
.agent-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.agent-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.agent-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.agent-card {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.agent-card p {
|
||||
line-height: 1.65;
|
||||
}
|
||||
.agent-copy-row .agent-address {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.agent-steps {
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.agent-steps li {
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
.agent-tutorial {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
line-height: 1.65;
|
||||
user-select: text;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.agent-details {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.agent-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.agent-details pre,
|
||||
.agent-details code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.agent-details ul {
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.agent-details li p {
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
}
|
||||
.agent-capabilities,
|
||||
.agent-scope-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.agent-scope-grid article {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.agent-panel .agent-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.agent-panel .agent-form > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.agent-scope-options {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
.agent-scope-options label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.agent-scope-options input {
|
||||
width: auto;
|
||||
flex: none;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.agent-scope-options small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.agent-secret,
|
||||
.agent-notice {
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.agent-row > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agent-row p {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.agent-layout,
|
||||
.agent-capabilities,
|
||||
.agent-scope-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.agent-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
.agent-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
.agent-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.agent-tabs button {
|
||||
min-height: 40px;
|
||||
}
|
||||
Reference in new issue
Block a user