783 lines
21 KiB
Vue
783 lines
21 KiB
Vue
<template>
|
||
<div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }">
|
||
<div class="secondary-overlay" v-if="!secondaryVerified">
|
||
<div class="secondary-card">
|
||
<h3>二级密码校验</h3>
|
||
<p class="muted">输入管理员二级密码后可管理自定义工具。</p>
|
||
<input
|
||
class="secondary-input"
|
||
type="password"
|
||
v-model="secondaryPassword"
|
||
:disabled="secondaryLoading"
|
||
placeholder="二级密码"
|
||
@keyup.enter="handleVerifySecondary"
|
||
/>
|
||
<div class="secondary-actions">
|
||
<button type="button" class="primary" :disabled="secondaryLoading" @click="handleVerifySecondary">
|
||
{{ secondaryLoading ? '校验中...' : '确认' }}
|
||
</button>
|
||
<button type="button" class="ghost" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
|
||
</div>
|
||
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
|
||
</div>
|
||
</div>
|
||
<!-- 列表模式 -->
|
||
<template v-if="!editorPageMode">
|
||
<header class="page-header">
|
||
<div>
|
||
<h1>自定义工具管理</h1>
|
||
<p>每个工具一个文件夹,三层文件独立存放;仅管理员可见并可调用。</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="button" class="primary" @click="openCreateModal">新建工具</button>
|
||
<button type="button" class="ghost" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button>
|
||
<a class="ghost" href="/static/custom_tools/guide.html" target="_blank" rel="noopener">查看开发指南</a>
|
||
<a class="ghost" href="/admin/monitor" target="_blank" rel="noopener">返回监控</a>
|
||
<a class="ghost" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="panel" v-if="error">
|
||
<p class="error">加载失败:{{ error }}</p>
|
||
</section>
|
||
|
||
<section class="panel" v-else>
|
||
<div class="tool-list-header">
|
||
<h2>工具列表</h2>
|
||
<span class="muted">共 {{ tools.length }} 个</span>
|
||
</div>
|
||
<div v-if="!tools.length" class="empty">暂无自定义工具</div>
|
||
<div class="tool-grid">
|
||
<div v-for="tool in tools" :key="tool.id" class="tool-card" @click="openEditorPage(tool)">
|
||
<div class="tool-card-title">
|
||
<strong>{{ tool.id }}</strong>
|
||
<span class="tag">{{ tool.category || 'custom' }}</span>
|
||
</div>
|
||
<p class="desc">{{ tool.description || '暂无描述' }}</p>
|
||
<div class="meta">
|
||
<span>参数:{{ paramCount(tool) }} 个</span>
|
||
<span>超时:{{ tool.timeout || 30 }}s</span>
|
||
</div>
|
||
<div class="files">
|
||
<span>{{ tool.execution_file || 'execution.py' }}</span>
|
||
<span>{{ tool.return_file || 'return.json' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- 纯编辑页面 -->
|
||
<section v-else class="editor-page">
|
||
<div class="editor-page-header">
|
||
<div class="breadcrumbs">
|
||
<a class="ghost" href="/admin/custom-tools">返回列表</a>
|
||
</div>
|
||
<div class="status" v-if="loading">加载中...</div>
|
||
<div class="status error" v-else-if="error">{{ error }}</div>
|
||
</div>
|
||
|
||
<div v-if="activeTool" class="editor-panel">
|
||
<header class="drawer-header">
|
||
<div>
|
||
<h3>{{ activeTool.id }}</h3>
|
||
<p>{{ activeTool.description || '未填写描述' }}</p>
|
||
</div>
|
||
<div class="drawer-actions">
|
||
<button type="button" class="primary" @click="saveAll" :disabled="saving">{{ saving ? '保存中...' : '保存' }}</button>
|
||
<button type="button" class="danger" @click="openDeleteConfirm()">删除</button>
|
||
<button type="button" class="ghost" @click="closeEditor">关闭</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<button :class="{active: tab==='definition'}" @click="tab='definition'">definition.json</button>
|
||
<button :class="{active: tab==='execution'}" @click="tab='execution'">execution.py</button>
|
||
<button :class="{active: tab==='return'}" @click="tab='return'">return.json</button>
|
||
<button :class="{active: tab==='meta'}" @click="tab='meta'">meta.json</button>
|
||
</div>
|
||
|
||
<div class="editor">
|
||
<textarea v-if="tab==='definition'" v-model="buffers.definition" spellcheck="false"></textarea>
|
||
<textarea v-else-if="tab==='execution'" v-model="buffers.execution" spellcheck="false"></textarea>
|
||
<textarea v-else-if="tab==='return'" v-model="buffers.return" spellcheck="false"></textarea>
|
||
<textarea v-else v-model="buffers.meta" spellcheck="false"></textarea>
|
||
</div>
|
||
|
||
<div class="hint">
|
||
<p>提示:execution.py 中的字典/集合需要用 <code v-pre>{{ ... }}</code> 包裹,避免被模板替换。</p>
|
||
<p>保存后无需重启,系统会自动 reload 自定义工具。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="!error" class="empty">正在加载工具...</div>
|
||
</section>
|
||
|
||
<!-- 创建工具弹窗 -->
|
||
<transition name="fade">
|
||
<div v-if="createModal" class="modal-backdrop">
|
||
<div class="modal">
|
||
<h3>创建新工具</h3>
|
||
<label>工具 ID(小写/下划线):<input v-model="createForm.id" /></label>
|
||
<label>描述:<input v-model="createForm.description" /></label>
|
||
<div class="modal-actions">
|
||
<button type="button" class="ghost" @click="createModal=false">取消</button>
|
||
<button type="button" class="primary" @click="createTool" :disabled="creating">{{ creating ? '创建中...' : '创建' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- 删除确认弹窗 -->
|
||
<transition name="fade">
|
||
<div v-if="confirmDeleteModal" class="modal-backdrop">
|
||
<div class="modal">
|
||
<h3>确认删除</h3>
|
||
<p class="delete-tip">确定删除工具 <strong>{{ deleteTargetId }}</strong> 吗?该操作不可恢复。</p>
|
||
<div class="modal-actions">
|
||
<button type="button" class="ghost" @click="confirmDeleteModal=false">取消</button>
|
||
<button type="button" class="danger" @click="performDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref, watch } from 'vue';
|
||
import { useSecondaryPass } from './useSecondaryPass';
|
||
|
||
interface CustomTool {
|
||
id: string;
|
||
description?: string;
|
||
parameters?: any;
|
||
category?: string;
|
||
icon?: string | null;
|
||
timeout?: number;
|
||
execution_file?: string;
|
||
return_file?: string;
|
||
}
|
||
|
||
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
|
||
const secondaryPassword = ref('');
|
||
|
||
const tools = ref<CustomTool[]>([]);
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
const activeTool = ref<CustomTool | null>(null);
|
||
const pendingOpenId = ref('');
|
||
const editorPageMode = ref(false);
|
||
const tab = ref<'definition' | 'execution' | 'return' | 'meta'>('definition');
|
||
const saving = ref(false);
|
||
const createModal = ref(false);
|
||
const creating = ref(false);
|
||
const confirmDeleteModal = ref(false);
|
||
const deleting = ref(false);
|
||
const deleteTargetId = ref('');
|
||
const createForm = reactive({ id: '', description: '' });
|
||
const buffers = reactive({ definition: '', execution: '', return: '', meta: '' });
|
||
|
||
const refresh = async () => {
|
||
if (!secondaryVerified.value) return;
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const res = await fetch('/api/admin/custom-tools', { credentials: 'same-origin' });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error || '加载失败');
|
||
tools.value = (data.data || []).map((t: any) => ({
|
||
...t,
|
||
execution_file: (t.execution && t.execution.file) || 'execution.py',
|
||
return_file: t.return ? 'return.json' : '(无返回层)'
|
||
}));
|
||
if (pendingOpenId.value) {
|
||
const target = tools.value.find((t) => t.id === pendingOpenId.value);
|
||
if (target) {
|
||
await openEditorInline(target);
|
||
} else {
|
||
error.value = `未找到工具 ${pendingOpenId.value}`;
|
||
}
|
||
pendingOpenId.value = '';
|
||
}
|
||
} catch (e: any) {
|
||
error.value = e.message;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const openEditorInline = async (tool: CustomTool) => {
|
||
activeTool.value = tool;
|
||
tab.value = 'definition';
|
||
await loadBuffers(tool.id);
|
||
};
|
||
|
||
const openEditorPage = (tool: CustomTool) => {
|
||
const url = `/admin/custom-tools?tool=${encodeURIComponent(tool.id)}`;
|
||
window.open(url, '_blank', 'noopener');
|
||
};
|
||
|
||
const loadBuffers = async (id: string) => {
|
||
if (!secondaryVerified.value) return;
|
||
try {
|
||
buffers.definition = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`);
|
||
buffers.execution = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=execution.py`);
|
||
buffers.return = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=return.json`, '');
|
||
buffers.meta = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=meta.json`, '');
|
||
} catch (e: any) {
|
||
error.value = e.message;
|
||
}
|
||
};
|
||
|
||
const fetchText = async (url: string, fallback = ''): Promise<string> => {
|
||
const res = await fetch(url, { credentials: 'same-origin' });
|
||
if (res.status === 404) return fallback;
|
||
const text = await res.text();
|
||
return text || fallback;
|
||
};
|
||
|
||
const saveAll = async () => {
|
||
if (!secondaryVerified.value) return;
|
||
if (!activeTool.value) return;
|
||
saving.value = true;
|
||
try {
|
||
await saveFile(activeTool.value.id, 'definition.json', buffers.definition);
|
||
await saveFile(activeTool.value.id, 'execution.py', buffers.execution);
|
||
await saveFile(activeTool.value.id, 'return.json', buffers.return);
|
||
await saveFile(activeTool.value.id, 'meta.json', buffers.meta);
|
||
await reloadRegistry();
|
||
await refresh();
|
||
} catch (e: any) {
|
||
error.value = e.message;
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
const saveFile = async (id: string, name: string, content: string) => {
|
||
const res = await fetch('/api/admin/custom-tools/file', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id, name, content })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error || `保存 ${name} 失败`);
|
||
};
|
||
|
||
const reloadRegistry = async () => {
|
||
await fetch('/api/admin/custom-tools/reload', { method: 'POST', credentials: 'same-origin' });
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!activeTool.value) return;
|
||
openDeleteConfirm(activeTool.value);
|
||
};
|
||
|
||
const openDeleteConfirm = (tool?: CustomTool) => {
|
||
const target = tool || activeTool.value;
|
||
if (!target) return;
|
||
deleteTargetId.value = target.id;
|
||
confirmDeleteModal.value = true;
|
||
};
|
||
|
||
const performDelete = async () => {
|
||
if (!secondaryVerified.value) return;
|
||
if (!deleteTargetId.value) return;
|
||
deleting.value = true;
|
||
try {
|
||
const res = await fetch(`/api/admin/custom-tools?id=${encodeURIComponent(deleteTargetId.value)}`, { method: 'DELETE', credentials: 'same-origin' });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error || '删除失败');
|
||
activeTool.value = null;
|
||
confirmDeleteModal.value = false;
|
||
deleteTargetId.value = '';
|
||
await refresh();
|
||
} catch (e: any) {
|
||
error.value = e.message;
|
||
}
|
||
deleting.value = false;
|
||
};
|
||
|
||
const openCreateModal = () => {
|
||
createForm.id = '';
|
||
createForm.description = '';
|
||
createModal.value = true;
|
||
};
|
||
|
||
const createTool = async () => {
|
||
if (!secondaryVerified.value) return;
|
||
const id = createForm.id.trim();
|
||
if (!id) {
|
||
error.value = '请填写工具 ID';
|
||
return;
|
||
}
|
||
const idPattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
||
if (!idPattern.test(id)) {
|
||
error.value = '工具 ID 需以字母开头,可包含字母/数字/_/-';
|
||
return;
|
||
}
|
||
creating.value = true;
|
||
try {
|
||
const payload = {
|
||
id,
|
||
description: createForm.description,
|
||
parameters: { type: 'object', properties: {}, required: [] },
|
||
execution_code: "print('hello')\n",
|
||
category: 'custom'
|
||
};
|
||
const res = await fetch('/api/admin/custom-tools', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error || '创建失败');
|
||
createModal.value = false;
|
||
activeTool.value = null;
|
||
await refresh();
|
||
window.location.href = `/admin/custom-tools?tool=${encodeURIComponent(id)}`;
|
||
} catch (e: any) {
|
||
error.value = e.message;
|
||
} finally {
|
||
creating.value = false;
|
||
}
|
||
};
|
||
|
||
const closeEditor = () => {
|
||
window.location.href = '/admin/custom-tools';
|
||
};
|
||
|
||
const paramCount = (tool: CustomTool) => {
|
||
const props = tool.parameters?.properties || {};
|
||
return Object.keys(props).length;
|
||
};
|
||
|
||
const handleVerifySecondary = async () => {
|
||
await verifySecondary(secondaryPassword.value);
|
||
if (secondaryVerified.value) {
|
||
secondaryPassword.value = '';
|
||
await refresh();
|
||
}
|
||
};
|
||
|
||
onMounted(async () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const toolId = params.get('tool');
|
||
if (toolId) {
|
||
pendingOpenId.value = toolId;
|
||
editorPageMode.value = true;
|
||
}
|
||
await checkSecondary();
|
||
if (secondaryVerified.value) {
|
||
await refresh();
|
||
}
|
||
});
|
||
|
||
watch(secondaryVerified, async (val) => {
|
||
if (val) {
|
||
await refresh();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Claude 经典:米色+白色+深棕的柔和高对比配色 */
|
||
:global(html, body) {
|
||
min-height: 100%;
|
||
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
|
||
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent),
|
||
#f8f3e8;
|
||
}
|
||
:global(#custom-tools-app) {
|
||
min-height: 100vh;
|
||
}
|
||
.custom-tools-page {
|
||
--sand: #f8f3e8;
|
||
--sand-strong: #efe5d6;
|
||
--paper: #ffffff;
|
||
--brown: #4b3626;
|
||
--brown-strong: #2f2015;
|
||
--ink: #1a120b;
|
||
--muted: #6f635a;
|
||
--border: #e3d8c8;
|
||
--glow: 0 14px 40px rgba(47, 32, 21, 0.12);
|
||
--radius-lg: 18px;
|
||
--radius-md: 12px;
|
||
|
||
width: 100%;
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 28px;
|
||
font-family: 'Space Grotesk', 'Noto Sans SC', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
color: var(--ink);
|
||
background: transparent;
|
||
}
|
||
.custom-tools-page.editor-only {
|
||
max-width: 1080px;
|
||
width: min(1080px, 100%);
|
||
padding: 20px 16px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.page-header h1 {
|
||
margin: 0;
|
||
letter-spacing: 0.4px;
|
||
color: var(--brown-strong);
|
||
}
|
||
.page-header p {
|
||
margin: 4px 0 0;
|
||
color: var(--muted);
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.panel {
|
||
background: var(--paper);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 18px;
|
||
box-shadow: var(--glow);
|
||
}
|
||
.tool-list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.tool-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
.tool-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: 14px;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s, background 0.2s;
|
||
background: linear-gradient(180deg, #fffdf8 0%, #ffffff 50%, #fbf6ed 100%);
|
||
}
|
||
.tool-card:hover {
|
||
border-color: var(--brown);
|
||
box-shadow: 0 12px 30px rgba(47, 32, 21, 0.12);
|
||
transform: translateY(-1px);
|
||
}
|
||
.tool-card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
.tool-card-title strong {
|
||
word-break: break-all;
|
||
color: var(--brown-strong);
|
||
}
|
||
.desc {
|
||
margin: 6px 0;
|
||
color: var(--muted);
|
||
min-height: 32px;
|
||
word-break: break-word;
|
||
}
|
||
.meta,
|
||
.files {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
flex-wrap: wrap;
|
||
}
|
||
.files span {
|
||
max-width: 100%;
|
||
word-break: break-all;
|
||
}
|
||
.tag {
|
||
background: rgba(75, 54, 38, 0.08);
|
||
color: var(--brown-strong);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
padding: 3px 10px;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
.drawer-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
.drawer-header h3 {
|
||
margin: 0;
|
||
}
|
||
.editor-panel {
|
||
margin-top: 16px;
|
||
background: var(--paper);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 18px;
|
||
box-shadow: var(--glow);
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.editor-page {
|
||
margin-top: 12px;
|
||
}
|
||
.editor-page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.breadcrumbs {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.status {
|
||
color: var(--muted);
|
||
}
|
||
.status.error {
|
||
color: #dc2626;
|
||
}
|
||
.editor-only {
|
||
max-width: 1080px;
|
||
}
|
||
.tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.tabs button {
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border);
|
||
background: #fdf8ef;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
}
|
||
.tabs button.active {
|
||
border-color: var(--brown);
|
||
color: var(--brown-strong);
|
||
background: #fff;
|
||
box-shadow: inset 0 0 0 1px rgba(75, 54, 38, 0.04);
|
||
}
|
||
.editor {
|
||
flex: 1;
|
||
min-height: 320px;
|
||
width: 100%;
|
||
overflow: auto;
|
||
}
|
||
.editor textarea {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 320px;
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||
font-size: 13px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 14px;
|
||
outline: none;
|
||
resize: vertical;
|
||
background: #fdf9f1;
|
||
color: var(--ink);
|
||
line-height: 1.5;
|
||
box-shadow: inset 0 2px 6px rgba(47, 32, 21, 0.04);
|
||
box-sizing: border-box;
|
||
max-width: 100%;
|
||
}
|
||
.hint {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(26, 18, 11, 0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2100;
|
||
}
|
||
.modal {
|
||
background: var(--paper);
|
||
padding: 18px;
|
||
border-radius: var(--radius-lg);
|
||
width: 360px;
|
||
max-width: min(420px, 90vw);
|
||
box-sizing: border-box;
|
||
box-shadow: 0 16px 40px rgba(47, 32, 21, 0.18);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.modal h3 {
|
||
margin: 0;
|
||
color: var(--brown-strong);
|
||
}
|
||
.modal input {
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
background: #fdf8ef;
|
||
box-sizing: border-box;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.delete-tip {
|
||
margin: 4px 0 8px;
|
||
color: var(--muted);
|
||
}
|
||
.error { color: #dc2626; }
|
||
.empty { color: var(--muted); padding: 12px; }
|
||
|
||
/* 按钮样式与主站一致风格 */
|
||
.primary,
|
||
.ghost,
|
||
.danger {
|
||
font-family: inherit;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
line-height: 1.1;
|
||
border-radius: 12px;
|
||
padding: 10px 18px;
|
||
}
|
||
.primary {
|
||
background: linear-gradient(135deg, var(--claude-accent, #da7756) 0%, var(--claude-accent-strong, #bd5d3a) 100%);
|
||
color: #fff;
|
||
border: none;
|
||
cursor: pointer;
|
||
box-shadow: 0 10px 22px rgba(47, 32, 21, 0.15);
|
||
}
|
||
.primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.ghost {
|
||
border: 1px solid var(--border);
|
||
text-decoration: none;
|
||
color: var(--brown-strong);
|
||
background: #fffdf8;
|
||
}
|
||
.ghost:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.danger {
|
||
background: linear-gradient(135deg, #c05621, #9c4221);
|
||
color: #fff;
|
||
border: none;
|
||
cursor: pointer;
|
||
box-shadow: 0 12px 28px rgba(156, 66, 33, 0.22);
|
||
}
|
||
.actions .primary,
|
||
.actions .ghost {
|
||
text-decoration: none;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.drawer-content {
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
@media (max-width: 640px) {
|
||
.custom-tools-page {
|
||
padding: 16px;
|
||
}
|
||
.page-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
.actions {
|
||
width: 100%;
|
||
}
|
||
.tool-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
}
|
||
.drawer {
|
||
width: 100%;
|
||
}
|
||
.drawer-content {
|
||
padding: 12px;
|
||
}
|
||
.editor textarea {
|
||
min-height: 220px;
|
||
}
|
||
}
|
||
|
||
.secondary-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(20, 12, 5, 0.35);
|
||
backdrop-filter: blur(6px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 999;
|
||
}
|
||
|
||
.secondary-card {
|
||
width: 360px;
|
||
max-width: 90vw;
|
||
background: #f6ecda;
|
||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||
border-radius: 18px;
|
||
padding: 20px;
|
||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
|
||
}
|
||
|
||
.secondary-card h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.secondary-card p {
|
||
margin: 0 0 12px;
|
||
color: #5b4b35;
|
||
}
|
||
|
||
.secondary-input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(44, 32, 19, 0.2);
|
||
margin-bottom: 12px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.secondary-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.secondary-actions .ghost {
|
||
background: transparent;
|
||
border: 1px dashed rgba(44, 32, 19, 0.35);
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
color: #2a2013;
|
||
}
|
||
|
||
.secondary-error {
|
||
color: #b5473d;
|
||
margin-top: 10px;
|
||
}
|
||
</style>
|