Add platform config website_url, admin tab, fix writer DB config fallback, add trigger endpoints

This commit is contained in:
Yuzhiran Dev
2026-05-20 09:38:56 +08:00
parent 55e5f3d166
commit 498165440f
84 changed files with 1988 additions and 242 deletions
+168 -18
View File
@@ -25,12 +25,14 @@
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
</div>
</div>
@@ -53,10 +55,10 @@
</div>
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="title" label="标题" min-width="120" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="100"></el-table-column>
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="140"></el-table-column>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="source" label="来源" width="100"></el-table-column>
<el-table-column prop="source" label="来源" width="140"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
@@ -129,18 +131,25 @@
</template>
<template v-else>
<el-table :data="llmConfigs" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="model" label="模型" min-width="180"></el-table-column>
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="100"></el-table-column>
<el-table-column prop="provider" label="供应商" width="110"></el-table-column>
<el-table-column prop="model" label="模型" min-width="160"></el-table-column>
<el-table-column prop="base_url" label="接口地址" min-width="200">
<template #default="scope">{{ scope.row.base_url ? scope.row.base_url.replace(/^https?:\/\//, '') : '-' }}</template>
</el-table-column>
<el-table-column prop="temperature" label="温度" width="70"></el-table-column>
<el-table-column prop="max_tokens" label="最大Token" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="系统提示词" min-width="150">
<template #default="scope">{{ (scope.row.system_prompt || '').slice(0, 30) }}{{ (scope.row.system_prompt || '').length > 30 ? '...' : '' }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="scope">
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
@@ -151,8 +160,9 @@
<div class="card-list-mobile">
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">供应商</span><span class="card-value">{{ item.provider }}</span></div>
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
<div class="card-row"><span class="card-label">温度</span><span class="card-value">{{ item.temperature }}</span></div>
<div class="card-row"><span class="card-label">温度/最大Token</span><span class="card-value">{{ item.temperature }} / {{ item.max_tokens }}</span></div>
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ item.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
@@ -162,6 +172,45 @@
</div>
</div>
<div v-if="activeTab === 'platformconfigs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showPlatformConfigDialog()">新增配置</el-button>
</div>
<div v-if="platformConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="platformConfigs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无平台配置</div>
</div>
</template>
<template v-else>
<el-table :data="platformConfigs" border stripe class="data-table" style="width:100%">
<el-table-column prop="platform" label="标识" width="80"></el-table-column>
<el-table-column prop="name" label="名称" width="80"></el-table-column>
<el-table-column prop="website_url" label="平台网址" min-width="160">
<template #default="scope"><a :href="scope.row.website_url" target="_blank" style="color:#409eff;">{{ scope.row.website_url }}</a></template>
</el-table-column>
<el-table-column label="字数范围" width="120">
<template #default="scope">{{ scope.row.min_words }} - {{ scope.row.max_words }}</template>
</el-table-column>
<el-table-column label="配图" width="100">
<template #default="scope">{{ scope.row.requires_image ? `${scope.row.image_count_min}-${scope.row.image_count_max}张` : '不需要' }}</template>
</el-table-column>
<el-table-column prop="api_endpoint" label="API地址" min-width="160"></el-table-column>
<el-table-column prop="default_format" label="默认格式" min-width="160">
<template #default="scope">{{ (scope.row.default_format || '').slice(0, 30) }}{{ (scope.row.default_format || '').length > 30 ? '...' : '' }}</template>
</el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope"><el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon><el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="scope">
<el-button size="small" @click="showPlatformConfigDialog(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div v-if="activeTab === 'systemconfigs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
@@ -396,6 +445,21 @@
</div>
</div>
<div v-if="activeTab === 'assistant'">
<div class="toolbar">
<el-button type="primary" size="small" @click="saveAssistantPrompt" :loading="assistantSaving">保存配置</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">AI 助手在各页面右下角显示悬浮按钮,点击可打开对话窗口</span>
</div>
<el-form label-width="120px" style="max-width:700px;margin-top:16px;">
<el-form-item label="系统提示词">
<el-input type="textarea" v-model="assistantPrompt" :rows="12" placeholder="设置 AI 助手的角色和行为说明,留空则使用默认提示词"></el-input>
</el-form-item>
<el-form-item label="启用助手" v-if="false">
<el-switch v-model="assistantEnabled"></el-switch>
</el-form-item>
</el-form>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
@@ -500,7 +564,7 @@
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="999999" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
@@ -516,6 +580,39 @@
</template>
</el-dialog>
<el-dialog v-model="platformConfigDialogVisible" :title="platformConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="platformConfigForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="标识"><el-input v-model="platformConfigForm.platform" :disabled="!!editingPlatformConfigPlatform" placeholder="zhihu"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="platformConfigForm.name" placeholder="知乎"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="图标"><el-input v-model="platformConfigForm.icon" placeholder="📕"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="平台网址"><el-input v-model="platformConfigForm.website_url" placeholder="https://www.zhihu.com"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="API地址"><el-input v-model="platformConfigForm.api_endpoint" placeholder="https://api.zhihu.com"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最少字数"><el-input-number v-model="platformConfigForm.min_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最多字数"><el-input-number v-model="platformConfigForm.max_words" :min="0" :max="50000" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="需要配图"><el-switch v-model="platformConfigForm.requires_image"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最少"><el-input-number v-model="platformConfigForm.image_count_min" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="最多"><el-input-number v-model="platformConfigForm.image_count_max" :min="0" :max="20" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="默认格式"><el-input type="textarea" v-model="platformConfigForm.default_format" :rows="2" placeholder="图文笔记,300-800字,emoji+标签"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="活跃"><el-switch v-model="platformConfigForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="platformConfigDialogVisible=false">取消</el-button>
<el-button type="primary" @click="savePlatformConfig">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="600px" :close-on-click-modal="false">
<el-form :model="systemConfigForm" label-width="80px">
<el-row :gutter="16">
@@ -666,7 +763,7 @@
const llmConfigsLoading = ref(false);
const llmConfigDialogVisible = ref(false);
const llmConfigDialogTitle = ref('新增配置');
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
const editingLLMConfigId = ref(null);
const loadLLMConfigs = async () => {
@@ -679,14 +776,14 @@
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 131072;
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
llmConfigForm.is_active = row.is_active ?? true;
} else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
llmConfigForm.max_tokens = 131072; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
}
llmConfigDialogVisible.value = true;
};
@@ -702,6 +799,37 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const platformConfigs = ref([]);
const platformConfigsLoading = ref(false);
const platformConfigDialogVisible = ref(false);
const platformConfigDialogTitle = ref('新增配置');
const platformConfigForm = reactive({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
const editingPlatformConfigPlatform = ref(null);
const loadPlatformConfigs = async () => {
platformConfigsLoading.value = true;
try { platformConfigs.value = await api.get('/api/platform-config'); } catch (e) { ElMessage.error('加载平台配置失败: ' + e.message); }
finally { platformConfigsLoading.value = false; }
};
const showPlatformConfigDialog = (row = null) => {
if (row) {
platformConfigDialogTitle.value = '编辑配置'; editingPlatformConfigPlatform.value = row.platform;
Object.assign(platformConfigForm, { ...row });
} else {
platformConfigDialogTitle.value = '新增配置'; editingPlatformConfigPlatform.value = null;
platformConfigForm.platform = ''; platformConfigForm.name = ''; platformConfigForm.icon = ''; platformConfigForm.website_url = ''; platformConfigForm.api_endpoint = '';
platformConfigForm.min_words = 300; platformConfigForm.max_words = 3000; platformConfigForm.requires_image = false;
platformConfigForm.image_count_min = 0; platformConfigForm.image_count_max = 0; platformConfigForm.default_format = ''; platformConfigForm.is_active = true;
}
platformConfigDialogVisible.value = true;
};
const savePlatformConfig = async () => {
try {
if (editingPlatformConfigPlatform.value) { await api.put(`/api/platform-config/${platformConfigForm.platform}`, platformConfigForm); ElMessage.success('更新成功'); }
else { await api.post('/api/platform-config', platformConfigForm); ElMessage.success('创建成功'); }
platformConfigDialogVisible.value = false; await loadPlatformConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const systemConfigs = ref([]);
const systemConfigsLoading = ref(false);
const systemConfigDialogVisible = ref(false);
@@ -841,6 +969,25 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const assistantPrompt = ref('');
const assistantEnabled = ref(true);
const assistantSaving = ref(false);
const loadAssistantConfig = async () => {
try {
const configs = await api.get('/api/admin/systemconfigs');
const cfg = configs.find(c => c.key === 'assistant_system_prompt');
assistantPrompt.value = cfg ? (typeof cfg.value === 'string' ? cfg.value : '') : '';
} catch (e) { console.error('加载AI助手配置失败', e); }
};
const saveAssistantPrompt = async () => {
assistantSaving.value = true;
try {
await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' });
ElementPlus.ElMessage.success('保存成功');
} catch (e) { ElementPlus.ElMessage.error('保存失败: ' + e.message); }
finally { assistantSaving.value = false; }
};
const loadCategories = async () => {
categoriesLoading.value = true;
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
@@ -896,10 +1043,10 @@
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs,
orgs: loadOrgs, assistant: loadAssistantConfig,
};
const loadedTabs = new Set(['cases']);
@@ -924,6 +1071,7 @@
casePage, casePageSize, paginatedCases,
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
platformConfigs, platformConfigsLoading, platformConfigDialogVisible, platformConfigForm, platformConfigDialogTitle, showPlatformConfigDialog, savePlatformConfig, editingPlatformConfigPlatform,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
categoryPage, categoryPageSize, paginatedCategories,
@@ -934,7 +1082,8 @@
logout, currentUser, isAdmin, redirectToPage,
users, usersLoading, userDialogVisible, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
fetchUsers, addUser, submitUser, deleteUser,
logType, logDate, logContent, logsLoading, fetchLogs
logType, logDate, logContent, logsLoading, fetchLogs,
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
};
}
});
@@ -944,5 +1093,6 @@
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+361
View File
@@ -0,0 +1,361 @@
(function () {
if (document.getElementById('ai-assistant-styles')) return;
var style = document.createElement('style');
style.id = 'ai-assistant-styles';
style.textContent = '\
.ai-assistant-btn {\
position: fixed; bottom: 24px; right: 24px;\
z-index: 99999;\
width: 52px; height: 52px;\
border-radius: 50%;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
border: none;\
cursor: pointer;\
box-shadow: 0 4px 16px rgba(37,99,235,0.35);\
display: flex; align-items: center; justify-content: center;\
font-size: 22px;\
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\
}\
.ai-assistant-btn:hover { transform: scale(1.1); box-shadow: 0 6px 24px rgba(37,99,235,0.45); }\
.ai-assistant-btn.open { transform: rotate(45deg); background: #f56c6c; box-shadow: 0 4px 16px rgba(245,108,108,0.35); }\
.ai-assistant-panel {\
position: fixed; bottom: 88px; right: 24px;\
z-index: 99998;\
width: 380px; height: 560px;\
background: #fff;\
border-radius: 16px;\
box-shadow: 0 8px 40px rgba(0,0,0,0.15);\
display: none;\
flex-direction: column;\
overflow: hidden;\
animation: aiSlideUp 0.3s ease-out;\
border: 1px solid #e5e7eb;\
}\
.ai-assistant-panel.open { display: flex; }\
@keyframes aiSlideUp {\
from { opacity: 0; transform: translateY(20px) scale(0.95); }\
to { opacity: 1; transform: translateY(0) scale(1); }\
}\
.ai-assistant-header {\
padding: 14px 16px;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
font-weight: 600;\
font-size: 15px;\
display: flex; align-items: center; gap: 8px;\
flex-shrink: 0;\
}\
.ai-assistant-header .ai-badge {\
background: rgba(255,255,255,0.2);\
padding: 2px 8px; border-radius: 10px;\
font-size: 10px; font-weight: 400;\
}\
.ai-assistant-messages {\
flex: 1; overflow-y: auto;\
padding: 12px 16px;\
background: #f8f9fb;\
display: flex; flex-direction: column; gap: 10px;\
}\
.ai-assistant-msg {\
max-width: 85%;\
padding: 10px 14px;\
border-radius: 12px;\
font-size: 13px;\
line-height: 1.6;\
word-break: break-word;\
white-space: pre-wrap;\
}\
.ai-assistant-msg.ai {\
background: #fff;\
border: 1px solid #e5e7eb;\
color: #303133;\
align-self: flex-start;\
border-bottom-left-radius: 4px;\
}\
.ai-assistant-msg.user {\
background: #2563eb;\
color: #fff;\
align-self: flex-end;\
border-bottom-right-radius: 4px;\
}\
.ai-assistant-msg .msg-time {\
font-size: 10px; opacity: 0.6;\
margin-top: 4px; display: block;\
}\
.ai-assistant-typing {\
align-self: flex-start;\
display: flex; gap: 4px;\
padding: 12px 16px;\
background: #fff;\
border: 1px solid #e5e7eb;\
border-radius: 12px;\
border-bottom-left-radius: 4px;\
}\
.ai-assistant-typing span {\
width: 6px; height: 6px;\
background: #909399;\
border-radius: 50%;\
animation: aiBounce 1.4s ease-in-out infinite;\
}\
.ai-assistant-typing span:nth-child(2) { animation-delay: 0.2s; }\
.ai-assistant-typing span:nth-child(3) { animation-delay: 0.4s; }\
@keyframes aiBounce {\
0%, 60%, 100% { transform: translateY(0); }\
30% { transform: translateY(-6px); }\
}\
.ai-assistant-input {\
display: flex; align-items: center;\
padding: 10px 12px;\
border-top: 1px solid #e5e7eb;\
background: #fff;\
flex-shrink: 0;\
gap: 8px;\
}\
.ai-assistant-input textarea {\
flex: 1;\
border: 1px solid #d1d5db;\
border-radius: 8px;\
padding: 8px 12px;\
font-size: 13px;\
outline: none;\
resize: none;\
height: 36px;\
line-height: 20px;\
font-family: inherit;\
transition: border-color 0.2s;\
}\
.ai-assistant-input textarea:focus { border-color: #2563eb; }\
.ai-assistant-send {\
width: 36px; height: 36px;\
border-radius: 8px;\
background: #2563eb;\
color: #fff;\
border: none;\
cursor: pointer;\
font-size: 16px;\
display: flex; align-items: center; justify-content: center;\
transition: all 0.2s;\
flex-shrink: 0;\
}\
.ai-assistant-send:hover { background: #1d4ed8; }\
.ai-assistant-send:disabled { background: #c0c4cc; cursor: not-allowed; }\
.ai-assistant-greeting {\
text-align: center;\
padding: 20px 16px;\
color: #909399;\
font-size: 13px;\
}\
.ai-assistant-greeting .greeting-icon {\
font-size: 36px; margin-bottom: 8px;\
}\
.ai-assistant-greeting h4 {\
color: #303133; font-size: 15px;\
margin: 0 0 6px;\
}\
@media (max-width: 480px) {\
.ai-assistant-panel {\
right: 12px; left: 12px;\
width: auto;\
height: 480px;\
bottom: 80px;\
}\
.ai-assistant-btn { right: 16px; bottom: 16px; }\
}\
';
document.head.appendChild(style);
var token = localStorage.getItem('authToken');
if (!token) return;
var pageMap = {
'/': 'dashboard', '/index.html': 'dashboard',
'topics.html': 'topics', 'metrics.html': 'metrics',
'calendar.html': 'calendar', 'assets.html': 'assets',
'tasks.html': 'tasks', 'platforms.html': 'platforms',
'admin.html': 'admin'
};
var path = window.location.pathname;
var currentPage = pageMap[path] || pageMap['/'];
var greettings = {
'dashboard': '👋 欢迎回来!我可以帮你了解仪表盘上的统计数据、模块状态的含义,或指导你如何快速定位待处理选题。',
'topics': '📝 这里是选题管理。我可以帮你了解如何创建选题、设置评分、编辑内容,或如何使用预览功能查看各平台文章效果。',
'metrics': '📊 数据分析页展示内容表现。我可以帮你解读图表数据、分析各平台效果差异,或建议如何提升内容互动率。',
'calendar': '📅 内容日历帮你规划发布节奏。我可以教你如何创建计划、调整日期,或优化内容排期策略。',
'assets': '🖼️ 素材库管理图片和文件。我可以指导你如何上传素材、添加标签分类,或关联到相关选题。',
'tasks': '⚡ 任务页面跟踪所有创作任务。我可以解释各任务的含义、查看执行进度,或排查任务失败原因。',
'platforms': '🔗 平台配置管理各发布渠道。我可以帮你了解如何配置平台参数、设置发布格式。',
'admin': '⚙️ 系统管理页面。我可以帮你管理用户、配置 LLM、查看运行日志,或设置采集类别和信息源。',
};
var HISTORY_KEY = 'ai_assistant_history_' + currentPage;
var saved = [];
try { saved = JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch(e) {}
var container = document.createElement('div');
container.innerHTML = '\
<button class="ai-assistant-btn" id="aiAssistantBtn" title="AI 助手">\
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>\
<path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/>\
<circle cx="9" cy="10" r="1" fill="currentColor"/>\
<circle cx="15" cy="10" r="1" fill="currentColor"/>\
<path d="M12 14v2"/>\
</svg>\
</button>\
<div class="ai-assistant-panel" id="aiAssistantPanel">\
<div class="ai-assistant-header">\
<span>🤖 AI 助手</span>\
<span class="ai-badge">' + currentPage + '</span>\
</div>\
<div class="ai-assistant-messages" id="aiMessages">\
</div>\
<div class="ai-assistant-input">\
<textarea id="aiInput" rows="1" placeholder="输入你的问题..." maxlength="500"></textarea>\
<button class="ai-assistant-send" id="aiSendBtn">\
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\
<line x1="22" y1="2" x2="11" y2="13"/>\
<polygon points="22 2 15 22 11 13 2 9 22 2"/>\
</svg>\
</button>\
</div>\
</div>\
';
document.body.appendChild(container);
var btn = document.getElementById('aiAssistantBtn');
var panel = document.getElementById('aiAssistantPanel');
var messagesEl = document.getElementById('aiMessages');
var input = document.getElementById('aiInput');
var sendBtn = document.getElementById('aiSendBtn');
function addMessage(role, content) {
var div = document.createElement('div');
div.className = 'ai-assistant-msg ' + role;
div.textContent = content;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function showGreeting() {
messagesEl.innerHTML = '\
<div class="ai-assistant-greeting">\
<div class="greeting-icon">🤖</div>\
<h4>宇之然 AI 助手</h4>\
<p>' + (greettings[currentPage] || '👋 你好!有什么可以帮助你的?') + '</p>\
</div>';
}
function showTyping() {
var div = document.createElement('div');
div.className = 'ai-assistant-typing';
div.id = 'aiTyping';
div.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeTyping() {
var t = document.getElementById('aiTyping');
if (t) t.remove();
}
function saveHistory(history) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-50)));
} catch(e) {}
}
async function sendMessage(text) {
if (!text.trim()) return;
input.value = '';
input.style.height = '36px';
addMessage('user', text);
if (messagesEl.querySelector('.ai-assistant-greeting')) {
messagesEl.querySelector('.ai-assistant-greeting').remove();
}
sendBtn.disabled = true;
var history = [];
var msgs = messagesEl.querySelectorAll('.ai-assistant-msg');
msgs.forEach(function(m) {
history.push({ role: m.classList.contains('user') ? 'user' : 'assistant', content: m.textContent });
});
showTyping();
try {
var resp = await fetch('/api/assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ message: text, page: currentPage, history: history })
});
removeTyping();
if (resp.ok) {
var data = await resp.json();
addMessage('assistant', data.reply || '已收到');
saveHistory(history.concat([
{ role: 'user', content: text },
{ role: 'assistant', content: data.reply || '已收到' }
]));
} else {
addMessage('assistant', '抱歉,请求失败,请稍后重试。');
}
} catch(e) {
removeTyping();
addMessage('assistant', '网络异常,请检查连接后重试。');
}
sendBtn.disabled = false;
input.focus();
}
// Open/close
var isOpen = false;
btn.addEventListener('click', function() {
isOpen = !isOpen;
btn.classList.toggle('open', isOpen);
panel.classList.toggle('open', isOpen);
if (isOpen) {
var msgs = messagesEl.querySelectorAll('.ai-assistant-msg');
if (msgs.length === 0) {
// Load saved or show greeting
if (saved.length > 0) {
saved.forEach(function(m) { addMessage(m.role, m.content); });
} else {
showGreeting();
}
}
input.focus();
}
});
// Send on click
sendBtn.addEventListener('click', function() { sendMessage(input.value); });
// Send on Enter (Shift+Enter for newline)
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
// Auto-resize textarea
input.addEventListener('input', function() {
this.style.height = '36px';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen) {
isOpen = false;
btn.classList.remove('open');
panel.classList.remove('open');
}
});
window.aiAssistantOpen = function() { btn.click(); };
window.aiAssistantClose = function() { if (isOpen) btn.click(); };
})();
+1
View File
@@ -257,5 +257,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -246,5 +246,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+3 -2
View File
@@ -96,9 +96,9 @@
<div class="calendar-header">
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon> 上月</el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
<el-button @click="nextMonth" size="large">下月 <el-icon><IconArrowRight /></el-icon></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
@@ -490,5 +490,6 @@ function getDayMeta(year, month, day) {
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+27 -2
View File
@@ -102,8 +102,13 @@
</div>
<div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run }}</span></div>
<div><span>今日任务</span><span>{{ mod.task_count }} 个</span></div>
<div v-if="mod.success_rate !== null"><span>成功率</span><span>{{ mod.success_rate }}%</span></div>
<div><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div>
<div style="margin-top:10px; border-bottom:none;">
<el-button v-if="mod.id === 'scheduled_optimize_sources'" size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
<el-button v-if="mod.id === 'scheduled_metrics_sync'" size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
</div>
</div>
</div>
</div>
@@ -158,7 +163,8 @@
schedulerStatus: '',
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false
loadingPlan: false,
runningModule: null
};
},
methods: {
@@ -276,6 +282,24 @@
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
window.location.href = url;
},
async triggerModule(modId) {
this.runningModule = modId;
try {
const token = localStorage.getItem('authToken');
const endpoint = modId === 'scheduled_optimize_sources' ? '/api/system/optimize-sources/run' : '/api/system/metrics-sync/run';
const resp = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } });
if (resp.ok) {
const data = await resp.json();
this.$message.success(data.message || '执行成功');
this.fetchModules();
} else {
const err = await resp.json().catch(() => ({}));
this.$message.error(err.detail || '执行失败');
}
} catch (e) {
this.$message.error('执行失败: ' + e.message);
} finally { this.runningModule = null; }
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
}
@@ -312,5 +336,6 @@
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -369,5 +369,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -246,5 +246,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+1
View File
@@ -292,5 +292,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>
+26 -11
View File
@@ -8,6 +8,9 @@
<link rel="stylesheet" href="theme-modern.css">
<style>
.selected-count { color: var(--color-text-secondary); font-size: var(--font-size-body); margin-left: auto; }
.preview-dialog-custom .el-dialog__body { overflow-y: auto; max-height: calc(90vh - 120px); padding: 16px 20px; }
.preview-dialog-custom .preview-body { max-height: none !important; min-height: 300px; overflow: visible; }
.preview-dialog-custom .preview-body img { max-width: 100%; }
.topic-card-list { display: none; }
@media (max-width: 768px) {
.el-table { display: none; }
@@ -50,7 +53,7 @@
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
</div>
<el-table :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table ref="topicTable" :data="paginatedTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
@@ -160,7 +163,7 @@
</div>
</div>
<div style="width:100%;">
<div v-if="!editing" class="preview-body" style="min-height:300px; height:68vh; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:20px; overflow-y:auto; width:100%; line-height:1.8;" v-html="currentPreviewBody"></div>
<div v-if="!editing" class="preview-body" style="border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:20px; width:100%; line-height:1.8;" v-html="currentPreviewBody"></div>
<div v-else ref="editor" contenteditable="true" style="min-height:300px; height:68vh; border:1px solid #409eff; border-radius:8px; background:#fff; padding:20px; overflow-y:auto; width:100%; line-height:1.8; outline:none; box-shadow:0 0 0 2px rgba(64,158,255,0.2);" @input="editContent = $event.target.innerHTML"></div>
</div>
</div>
@@ -237,11 +240,13 @@ const TopicsApp = {
currentPreviewBody() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
if (html.length < 50) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>该平台内容暂未生成或内容为空</p><p style="font-size:13px;margin-top:8px;">可点击上方「批量创作」重新生成,或在编辑模式下手动添加内容</p></div>';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
if (body.innerHTML.length < 30) return '<div style="padding:40px;text-align:center;color:#909399;"><p style="font-size:36px;margin:0 0 12px;">📝</p><p>内容为空,请编辑或重新生成</p></div>';
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
return body.innerHTML;
} catch (e) { console.error('解析 HTML 失败:', e); return html; }
@@ -340,8 +345,18 @@ const TopicsApp = {
},
platformName(platform) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[platform] || platform; },
copyContent(platform) {
if (!this.previewTopic || !this.previewTopic.content) { this.$message.warning('暂无内容可复制'); return; }
navigator.clipboard.writeText(`标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`)
const html = this.platformContents[platform];
if (!html) { this.$message.warning('暂无内容可复制'); return; }
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const titleEl = doc.querySelector('h1');
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
const body = doc.body;
if (body) {
body.querySelectorAll('script, nav, .header, .tags, footer, .interaction').forEach(el => el.remove());
}
const text = body ? body.textContent.trim() : html.replace(/<[^>]+>/g, '').trim();
navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
.then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
.catch(() => this.$message.error('复制失败,请手动复制'));
},
@@ -433,13 +448,12 @@ const TopicsApp = {
}
},
toggleSelectAll() {
const visible = this.filteredTopics.map(t => t.id);
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
if (allChecked) {
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
} else {
const existing = new Set(this.selectedTopicIds);
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
const table = this.$refs.topicTable;
if (!table) return;
const visible = this.filteredTopics;
const allChecked = visible.every(t => this.selectedTopicIds.includes(t.id));
for (const row of visible) {
table.toggleRowSelection(row, !allChecked);
}
},
formatDate(dateStr) {
@@ -467,5 +481,6 @@ if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>