Add pagination to admin management tabs (cases/categories/sources/orgs)

This commit is contained in:
Yuzhiran Dev
2026-05-18 19:18:09 +08:00
parent 7169ddb678
commit a09e7e6671
14 changed files with 455 additions and 340 deletions
+202 -11
View File
@@ -26,9 +26,11 @@
<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 === '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>
</div>
</div>
@@ -48,7 +50,7 @@
<div class="stat-summary">
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
</div>
<el-table :data="cases" border stripe class="data-table" style="width:100%">
<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>
@@ -61,9 +63,12 @@
</template>
</el-table-column>
</el-table>
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="cases.length" :page-size="casePageSize" :current-page="casePage" @current-change="casePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile">
<div v-for="item in cases" :key="item.id" class="card-item">
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
@@ -198,6 +203,71 @@
</div>
</div>
<div v-if="activeTab === 'users'">
<div class="toolbar">
<el-button type="primary" size="small" @click="addUser">+ 新建用户</el-button>
<el-button size="small" @click="fetchUsers">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ users.length }} 个</span>
</div>
<div v-if="usersLoading" class="card-loading">加载中...</div>
<template v-else-if="users.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUser /></el-icon>
<div class="empty-text">暂无用户</div>
<el-button type="primary" size="small" @click="addUser">新增第一个用户</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="users.length > userPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="users.length" :page-size="userPageSize" :current-page="userPage" @current-change="userPage = $event"></el-pagination>
</div>
<div class="card-list-mobile">
<div v-for="u in users" :key="u.id" class="card-item">
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ u.username }} <el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag></span></div>
<div class="card-row"><span class="card-label">组织</span><span class="card-value">{{ u.org_id || '-' }}</span></div>
<div class="card-row"><span class="card-label">创建时间</span><span class="card-value">{{ formatDate(u.created_at) }}</span></div>
<div class="card-actions">
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
</div>
</div>
</div>
</template>
<el-dialog v-model="userDialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
<el-form :model="userForm" label-width="80px">
<el-form-item label="用户名" required>
<el-input v-model="userForm.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="userForm.password" type="password" placeholder="至少6位" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
@@ -212,7 +282,7 @@
</div>
</template>
<template v-else>
<el-table :data="categories" border stripe class="data-table" style="width:100%">
<el-table :data="paginatedCategories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
@@ -230,9 +300,12 @@
</template>
</el-table-column>
</el-table>
<div v-if="categories.length > categoryPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="categories.length" :page-size="categoryPageSize" :current-page="categoryPage" @current-change="categoryPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in categories" :key="item.id" class="card-item">
<div v-for="item in paginatedCategories" :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.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
@@ -259,7 +332,7 @@
</div>
</template>
<template v-else>
<el-table :data="orgs" border stripe class="data-table" style="width:100%">
<el-table :data="paginatedOrgs" border stripe class="data-table" style="width:100%">
<el-table-column prop="org_id" label="组织ID" min-width="120"></el-table-column>
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
@@ -277,9 +350,12 @@
</template>
</el-table-column>
</el-table>
<div v-if="orgs.length > orgPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="orgs.length" :page-size="orgPageSize" :current-page="orgPage" @current-change="orgPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="orgs.length > 0">
<div v-for="item in orgs" :key="item.org_id" class="card-item">
<div v-for="item in paginatedOrgs" :key="item.org_id" class="card-item">
<div class="card-row"><span class="card-label">ID</span><span class="card-value">{{ item.org_id }}</span></div>
<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.user_count }}</span></div>
@@ -292,6 +368,32 @@
</div>
</div>
<div v-if="activeTab === 'logs'">
<div class="toolbar">
<el-select v-model="logType" placeholder="日志类型" style="width:200px;">
<el-option label="创作日志" value="creator"></el-option>
<el-option label="审查日志" value="optimizer"></el-option>
<el-option label="研究日志" value="research"></el-option>
<el-option label="大纲日志" value="outline"></el-option>
<el-option label="写作日志" value="writer"></el-option>
<el-option label="发布日志" value="publisher"></el-option>
<el-option label="收集日志" value="collector"></el-option>
<el-option label="通知日志" value="notifier"></el-option>
<el-option label="趋势日志" value="trends"></el-option>
</el-select>
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width:200px;"></el-date-picker>
<el-button type="primary" @click="fetchLogs" :loading="logsLoading">加载日志</el-button>
</div>
<div v-if="logsLoading" class="card-loading">加载中...</div>
<div v-else-if="!logContent" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">选择日志类型和日期后点击加载</div>
</div>
<div v-else class="log-container" style="max-height:600px;overflow-y:auto;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;">
<pre style="margin:0;padding:16px;white-space:pre-wrap;word-wrap:break-word;font-size:13px;line-height:1.6;color:#303133;">{{ logContent }}</pre>
</div>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
@@ -307,7 +409,7 @@
</div>
</template>
<template v-else>
<el-table :data="sources" border stripe class="data-table" style="width:100%">
<el-table :data="paginatedSources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
@@ -328,9 +430,12 @@
</template>
</el-table-column>
</el-table>
<div v-if="sources.length > sourcePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="sources.length" :page-size="sourcePageSize" :current-page="sourcePage" @current-change="sourcePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in sources" :key="item.id" class="card-item">
<div v-for="item in paginatedSources" :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.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
@@ -483,7 +588,7 @@
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
const { createApp, ref, reactive, computed, onMounted } = Vue;
const { ElMessage, ElMessageBox } = ElementPlus;
const app = createApp({
@@ -513,7 +618,7 @@
})
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/login.html'; });
const redirectToPage = (page) => { window.location.href = '/' + page; };
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
const cases = ref([]);
const casesLoading = ref(false);
@@ -543,6 +648,9 @@
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const casePage = ref(1);
const casePageSize = 20;
const paginatedCases = computed(() => { const s = (casePage.value - 1) * casePageSize; return cases.value.slice(s, s + casePageSize); });
const taskLogs = ref([]);
const taskLogsLoading = ref(false);
@@ -642,6 +750,72 @@
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const orgPage = ref(1);
const orgPageSize = 20;
const paginatedOrgs = computed(() => { const s = (orgPage.value - 1) * orgPageSize; return orgs.value.slice(s, s + orgPageSize); });
const users = ref([]);
const usersLoading = ref(false);
const userDialogVisible = ref(false);
const userSubmitting = ref(false);
const userForm = reactive({ username: '', password: '', role: 'editor' });
const userPage = ref(1);
const userPageSize = 20;
const paginatedUsers = computed(() => {
const start = (userPage.value - 1) * userPageSize;
return users.value.slice(start, start + userPageSize);
});
const fetchUsers = async () => {
usersLoading.value = true;
try { users.value = await api.get('/api/admin/users'); } catch (e) { ElMessage.error('加载用户失败: ' + e.message); }
finally { usersLoading.value = false; }
};
const addUser = () => { userForm.username = ''; userForm.password = ''; userForm.role = 'editor'; userDialogVisible.value = true; };
const submitUser = async () => {
if (!userForm.username || userForm.username.length < 2) { ElMessage.warning('用户名至少2个字符'); return; }
if (!userForm.password || userForm.password.length < 6) { ElMessage.warning('密码至少6位'); return; }
userSubmitting.value = true;
try {
const u = await api.post('/api/admin/users', { ...userForm });
users.value.push(u);
ElMessage.success('创建成功');
userDialogVisible.value = false;
} catch (e) { ElMessage.error('创建失败: ' + e.message); }
finally { userSubmitting.value = false; }
};
const deleteUser = async (id) => {
if (id === 'admin') { ElMessage.warning('不能删除管理员用户'); return; }
try {
await ElMessageBox.confirm('确定删除该用户?', '提示', { type: 'warning' });
await api.delete(`/api/admin/users/${encodeURIComponent(id)}`);
users.value = users.value.filter(u => u.id !== id);
ElMessage.success('删除成功');
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const logType = ref('creator');
const logDate = ref(new Date().toISOString().slice(0, 10));
const logContent = ref('');
const logsLoading = ref(false);
const fetchLogs = async () => {
logsLoading.value = true;
try {
const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logType.value}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` }
});
if (resp.ok) {
const data = await resp.json();
const lines = data.content || [];
logContent.value = lines.join('\n');
} else if (resp.status === 404) {
logContent.value = '该日暂无日志';
} else {
logContent.value = `请求失败: ${resp.status}`;
}
} catch (e) {
logContent.value = '加载失败: ' + e.message;
} finally { logsLoading.value = false; }
};
const loadSystemConfigs = async () => {
systemConfigsLoading.value = true;
@@ -686,6 +860,9 @@
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const categoryPage = ref(1);
const categoryPageSize = 20;
const paginatedCategories = computed(() => { const s = (categoryPage.value - 1) * categoryPageSize; return categories.value.slice(s, s + categoryPageSize); });
const loadSources = async () => {
sourcesLoading.value = true;
@@ -708,12 +885,17 @@
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const sourcePage = ref(1);
const sourcePageSize = 20;
const paginatedSources = computed(() => { const s = (sourcePage.value - 1) * sourcePageSize; return sources.value.slice(s, s + sourcePageSize); });
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs,
};
@@ -730,18 +912,27 @@
onMounted(() => {
loadedTabs.add('cases');
loadCases();
const hash = window.location.hash.replace('#tab=', '');
if (hash && tabLoaders[hash]) { switchTab(hash); }
});
return {
activeTab, switchTab,
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
casePage, casePageSize, paginatedCases,
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
categoryPage, categoryPageSize, paginatedCategories,
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
sourcePage, sourcePageSize, paginatedSources,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
logout, currentUser, isAdmin, redirectToPage
orgPage, orgPageSize, paginatedOrgs,
logout, currentUser, isAdmin, redirectToPage,
users, usersLoading, userDialogVisible, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
fetchUsers, addUser, submitUser, deleteUser,
logType, logDate, logContent, logsLoading, fetchLogs
};
}
});
+86 -4
View File
@@ -24,6 +24,13 @@
.day-holiday { font-size: 8px; padding: 0 2px; }
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
.stats-bar { gap: 12px; font-size: 12px; }
.calendar-guide { flex-direction: column; padding: 12px; }
.calendar-guide-icon { font-size: 22px; }
.calendar-guide-content h3 { font-size: 15px; }
.calendar-guide-content li, .calendar-guide-content p { font-size: 12px; }
.upcoming-section h3 { font-size: 14px; }
.upcoming-item { width: 140px; padding: 10px; }
.calendar-hint { font-size: 11px; }
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
@@ -51,6 +58,24 @@
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
.calendar-guide { background: linear-gradient(135deg, #eef2ff, #e0e7ff); border: 1px solid #c7d2fe; border-radius: 12px; padding: 16px 20px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px; position: relative; }
.calendar-guide-close { position: absolute; top: 8px; right: 8px; background: none; border: none; color: #6366f1; cursor: pointer; font-size: 18px; padding: 4px; border-radius: 4px; }
.calendar-guide-close:hover { background: rgba(99,102,241,0.1); }
.calendar-guide-icon { font-size: 28px; flex-shrink: 0; margin-top: 2px; }
.calendar-guide-content { flex: 1; }
.calendar-guide-content h3 { font-size: 16px; font-weight: 600; color: #4338ca; margin: 0 0 6px 0; }
.calendar-guide-content p { font-size: 13px; color: #4f46e5; margin: 0; line-height: 1.6; }
.calendar-guide-content ol { margin: 6px 0 0 0; padding-left: 20px; }
.calendar-guide-content li { font-size: 13px; color: #4f46e5; line-height: 1.8; }
.upcoming-section { margin-bottom: 20px; }
.upcoming-section h3 { font-size: 16px; font-weight: 600; color: #303133; margin: 0 0 10px 0; display: flex; align-items: center; gap: 8px; }
.upcoming-list { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; }
.upcoming-item { flex-shrink: 0; width: 180px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; cursor: pointer; transition: all 0.2s; }
.upcoming-item:hover { border-color: #6366f1; box-shadow: 0 2px 8px rgba(99,102,241,0.15); }
.upcoming-date { font-size: 12px; color: #6366f1; font-weight: 600; }
.upcoming-title { font-size: 14px; color: #1f2937; font-weight: 500; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upcoming-meta { font-size: 12px; color: #9ca3af; margin-top: 6px; display: flex; gap: 8px; }
.calendar-hint { text-align: center; font-size: 12px; color: #c0c4cc; margin-top: 8px; padding: 4px 0; }
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
.stat-dot { width: 10px; height: 10px; border-radius: 50%; }
@@ -78,8 +103,42 @@
</div>
</div>
<!-- 使用指南 -->
<div v-if="showGuide" class="calendar-guide">
<div class="calendar-guide-icon">📅</div>
<div class="calendar-guide-content">
<h3>内容日历使用说明</h3>
<p>规划和管理各平台的内容发布排期,一目了然看到每月发布计划。</p>
<ol>
<li><strong>点击日期</strong> — 查看该日所有日程,或点击"添加日程"新建</li>
<li><strong>点击日程卡片</strong> — 编辑或删除已有日程</li>
<li><strong>关联选题</strong> — 创建日程时可关联已有选题,跟踪从创作到发布的全流程</li>
</ol>
</div>
<button class="calendar-guide-close" @click="showGuide = false; localStorage.setItem('calendarGuideDismissed', 'true')" title="不再显示"></button>
</div>
<!-- 即将到来 -->
<div v-if="upcomingEntries.length > 0" class="upcoming-section">
<h3><el-icon><IconClock /></el-icon> 即将到来</h3>
<div class="upcoming-list">
<div v-for="entry in upcomingEntries" :key="entry.id" class="upcoming-item" @click="openEntryDialog(entry)">
<div class="upcoming-date">{{ formatDate(entry.planned_date) }}</div>
<div class="upcoming-title">{{ entry.title || '未命名' }}</div>
<div class="upcoming-meta">
<span>{{ platformName(entry.platform) }}</span>
<el-tag :type="statusType(entry.status)" size="small">{{ statusLabel(entry.status) }}</el-tag>
</div>
</div>
</div>
</div>
<div class="stats-bar">
<div v-if="loadingCalendar" style="color:#909399;font-size:14px;">加载中...</div>
<div v-else-if="errorMsg" class="empty-state" style="color:#F56C6C;">
<el-icon style="font-size:36px;color:#F56C6C;"><IconClose /></el-icon>
<div class="empty-text">{{ errorMsg }}</div>
</div>
<div v-else-if="!calendarDays.length" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">暂无日历数据</div>
@@ -113,15 +172,21 @@
</div>
</div>
</div>
<div class="calendar-hint">💡 点击日期查看或添加日程 · 点击日程卡片编辑详情</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="dayDialogVisible" :title="selectedDay ? `${selectedDay.year}-${selectedDay.month}-${selectedDay.day} 日程` : ''" width="500px">
<el-dialog v-model="dayDialogVisible" :title="selectedDay ? `${selectedDay.year}${selectedDay.month}${selectedDay.day} 日程` : ''" width="500px">
<div v-if="selectedDay">
<el-button type="primary" @click="openCreateDialog" style="margin-bottom: 16px;">+ 添加日程</el-button>
<el-table :data="selectedDayEntries" stripe>
<div v-if="selectedDayEntries.length === 0" class="empty-state" style="padding:20px 0;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">该日暂无日程</div>
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
</div>
<el-table v-else :data="selectedDayEntries" stripe>
<el-table-column prop="title" label="标题" min-width="120"></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
@@ -321,11 +386,28 @@ function getDayMeta(year, month, day) {
const loadingCalendar = ref(false);
const calendarError = ref('');
const showGuide = ref(localStorage.getItem('calendarGuideDismissed') !== 'true');
const errorMsg = ref('');
const upcomingEntries = computed(() => {
const today = new Date(); today.setHours(0, 0, 0, 0);
const end = new Date(today); end.setDate(end.getDate() + 7);
return entries.value
.filter(e => { const d = new Date(e.planned_date); return d >= today && d < end; })
.sort((a, b) => new Date(a.planned_date) - new Date(b.planned_date))
.slice(0, 10);
});
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getMonth() + 1}${d.getDate()}`;
};
const fetchEntries = async () => {
loadingCalendar.value = true;
calendarError.value = '';
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; ElMessage.error('加载日历失败: ' + e.message); }
errorMsg.value = '';
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
finally { loadingCalendar.value = false; }
};
const fetchStats = async () => {
@@ -398,7 +480,7 @@ function getDayMeta(year, month, day) {
checkAuth();
});
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, topicStatusLabel, handleLogout, redirectToPage };
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, topicStatusLabel, handleLogout, redirectToPage, showGuide, errorMsg, upcomingEntries, formatDate };
}
};
+53 -1
View File
@@ -24,6 +24,11 @@
.module-content div { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
.module-content div:last-child { border-bottom: none; }
.stat-title { font-size: var(--font-size-small); color: var(--color-text-secondary); margin-bottom: var(--spacing-md); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; }
.plan-list { display: flex; flex-direction: column; gap: 8px; }
.plan-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-bg-raised); border: 1px solid var(--color-border); border-radius: var(--radius-md); transition: all 0.2s; }
.plan-item:hover { border-color: var(--color-primary); }
.plan-date { font-size: var(--font-size-caption); color: var(--color-text-secondary); font-weight: 600; min-width: 36px; }
.plan-title { flex: 1; font-size: var(--font-size-body); color: var(--color-text-primary); }
@media (max-width: 768px) { .module-grid { grid-template-columns: 1fr; } }
</style>
@@ -102,6 +107,26 @@
</div>
</div>
</div>
<!-- 近期计划 -->
<h3 style="font-size: var(--font-size-h2); font-weight: 600; margin-bottom: var(--spacing-lg); color: var(--color-text-primary);">
<el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 近期计划
</h3>
<div v-if="loadingPlan" style="text-align:center;padding:20px 0;color:#909399;">
<el-icon style="font-size:24px;" class="is-loading"><IconLoading /></el-icon>
<div style="margin-top:8px;">加载中...</div>
</div>
<div v-else-if="upcomingEntries.length === 0" class="empty-state" style="padding:20px;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">近期暂无计划</div>
</div>
<div v-else class="plan-list">
<div v-for="entry in upcomingEntries" :key="entry.id || entry.date" class="plan-item">
<span class="plan-date">{{ entry.date.slice(5) }}</span>
<span class="plan-title">{{ entry.title }}</span>
<el-tag v-if="entry.status" :type="entry.status === 'published' ? 'success' : 'warning'" size="small">{{ entry.status === 'published' ? '已发布' : '计划' }}</el-tag>
</div>
</div>
</div>
</main>
</div>
@@ -131,7 +156,9 @@
},
modules: [],
schedulerStatus: '',
schedulerRunning: false
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false
};
},
methods: {
@@ -221,6 +248,30 @@
this.$message.error('获取模块状态失败: ' + e.message);
}
},
async fetchUpcomingPlan() {
this.loadingPlan = true;
try {
const token = localStorage.getItem('authToken');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const resp = await fetch(`/api/calendar?year=${year}&month=${month}`, {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const all = await resp.json();
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = new Date(today);
end.setDate(end.getDate() + 7);
this.upcomingEntries = (all || [])
.filter(e => { const d = new Date(e.date); return d >= today && d < end; })
.sort((a, b) => a.date.localeCompare(b.date));
}
} catch (e) {
console.error('获取近期计划失败:', e);
} finally { this.loadingPlan = false; }
},
goToTopics(filter) {
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
window.location.href = url;
@@ -246,6 +297,7 @@
this.currentPage = 'overview';
this.fetchStats();
this.fetchModules();
this.fetchUpcomingPlan();
})
.catch(() => {
localStorage.removeItem('authToken');
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
</head>
<body>
<body class="login-body">
<!-- 背景装饰 -->
<div class="bg-circle"></div>
<div class="bg-circle"></div>
+9 -97
View File
@@ -3,107 +3,19 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 系统日志</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<title>宇之然 - 运行日志</title>
<style>
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
@media (max-width: 768px) {
.controls { flex-direction: column; align-items: stretch; }
.controls .el-select, .controls .el-date-picker { width: 100% !important; }
.controls .el-button { width: 100%; }
.log-container { max-height: calc(100vh - 280px); }
.log-container pre { font-size: 11px; padding: 12px; }
}
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-family: system-ui, sans-serif; }
.msg { text-align: center; padding: 20px; }
.msg h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
.msg p { font-size: 14px; opacity: 0.8; }
</style>
<script src="uni-nav.js"></script>
<script>window.location.href = '/admin.html#tab=logs';</script>
</head>
<body>
<div id="app">
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 系统日志</h2>
</div>
<div class="controls">
<el-select v-model="logType" placeholder="日志类型" style="width: 200px;">
<el-option label="创作日志" value="creator"></el-option>
<el-option label="审查日志" value="optimizer"></el-option>
<el-option label="研究日志" value="research"></el-option>
<el-option label="大纲日志" value="outline"></el-option>
<el-option label="写作日志" value="writer"></el-option>
<el-option label="发布日志" value="publisher"></el-option>
<el-option label="收集日志" value="collector"></el-option>
<el-option label="通知日志" value="notifier"></el-option>
<el-option label="趋势日志" value="trends"></el-option>
</el-select>
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD"></el-date-picker>
<el-button type="primary" @click="fetchLogs" :loading="loadingLogs">加载日志</el-button>
</div>
<div v-if="logContent" class="log-container">
<pre>{{ logContent }}</pre>
</div>
<el-empty v-else description="请选择类型和日期,点击加载"></el-empty>
</div>
</main>
</div>
<div class="msg">
<h2>运行日志已合并</h2>
<p>正在跳转到 系统管理 → 运行日志...</p>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const LogsApp = {
data() {
const today = new Date().toISOString().split('T')[0];
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
logType: 'creator', logDate: today, logContent: '', loadingLogs: false
}
},
methods: {
async fetchLogs() {
if (!this.logType || !this.logDate) { this.$message.warning('请选择日志类型和日期'); return; }
this.loadingLogs = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
const response = await fetch(`/api/logs?type=${encodeURIComponent(this.logType)}&date=${encodeURIComponent(this.logDate)}`, {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const data = await response.json();
this.logContent = `日志类型:${data.type}\n日期:${data.date}\n\n${data.content || '(日志文件为空)'}`;
this.$message.success('日志加载成功');
} catch (error) {
console.error('获取日志失败:', error);
this.$message.error(`获取日志失败: ${error.message}`);
this.logContent = '';
} finally { this.loadingLogs = false; }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
},
mounted() { this.checkAuth(); }
};
const app = Vue.createApp(LogsApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
-18
View File
@@ -51,24 +51,6 @@
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<div v-else class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ dashboard.total_topics }}</div>
<div class="stat-label">选题总数</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ dashboard.total_published }}</div>
<div class="stat-label">已发布</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ dashboard.total_views }}</div>
<div class="stat-label">总阅读</div>
</div>
<div class="stat-card danger">
<div class="stat-value">{{ dashboard.avg_engagement_rate }}%</div>
<div class="stat-label">平均互动率</div>
</div>
</div>
<div class="card page-fade">
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 选题状态分布</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items:center;">
+18 -4
View File
@@ -75,15 +75,19 @@
<div class="schedule-info">
<div class="schedule-name">{{ job.name }}</div>
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</div>
<div style="font-size:11px;color:#c0c4cc;margin-top:1px;">{{ job.id }}</div>
</div>
<div class="schedule-next">
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
<span>{{ job.active ? '运行中' : '等待中' }}</span>
<span v-if="job.next_run" style="margin-left: 8px; color: #909399;">下次: {{ job.next_run }}</span>
</div>
</div>
<div v-if="totalTasks > pageSize" style="display:flex;justify-content:center;margin-top:16px;">
<el-pagination background layout="prev, pager, next" :total="totalTasks" :page-size="pageSize" :current-page="currentPage" @current-change="p => { currentPage = p; loadTasks(); }"></el-pagination>
</div>
</div>
</div>
</div>
<div class="card page-fade">
<div class="page-header">
@@ -109,7 +113,8 @@
<div v-for="task in tasks" :key="task.task_id" class="task-card">
<div class="task-header">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<span class="task-id">#{{ task.task_id }}</span>
<span class="task-id" title="任务ID">{{ task.task_id.slice(0,16) }}</span>
<span style="font-weight:600;color:#303133;font-size:14px;">{{ task.topic_title || task.topic_id || '通用任务' }}</span>
<span class="status-badge" :class="'status-' + task.status">{{ getStatusLabel(task.status) }}</span>
<span class="task-stage" :class="'stage-' + task.stage">{{ getStageLabel(task.stage) }}</span>
</div>
@@ -126,6 +131,7 @@
</div>
</div>
<div class="task-meta">
<span>ID: {{ task.task_id }}</span>
<span>创建: {{ formatDate(task.created_at) }}</span>
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
<span v-if="task.finished_at">完成: {{ formatDate(task.finished_at) }}</span>
@@ -166,15 +172,21 @@
const TasksApp = {
data() {
const SCHEDULER_JOBS = {
'scheduled_collect': { icon: 'IconRefresh', name: '内容采集', defaultTime: '01:30' },
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
'scheduled_optimize_sources': { icon: 'IconSetting', name: '信息源优化', defaultTime: '05:00' },
'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' },
};
const STAGE_NAMES = { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' };
const STATUS_NAMES = { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' };
return {
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
tasks: [], loading: false, filterStatus: '',
showDetailDialog: false, detailTask: null,
schedulerJobs: [], schedulerLoading: false,
totalTasks: 0, currentPage: 1, pageSize: 20,
SCHEDULER_JOBS,
}
},
@@ -227,9 +239,11 @@ const TasksApp = {
async loadTasks() {
this.loading = true;
try {
let url = '/api/tasks?limit=50';
let url = `/api/tasks?limit=${this.pageSize}&offset=${(this.currentPage - 1) * this.pageSize}`;
if (this.filterStatus) url += '&status=' + this.filterStatus;
this.tasks = await this.api(url) || [];
const data = await this.api(url) || [];
this.tasks = data;
this.totalTasks = data.length < this.pageSize ? (this.currentPage - 1) * this.pageSize + data.length : this.currentPage * this.pageSize + 1;
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
finally { this.loading = false; }
},
+38 -10
View File
@@ -50,7 +50,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="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table :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>
@@ -76,7 +76,10 @@
</template>
</el-table-column>
</el-table>
<div v-if="!loadingTable && filteredTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
<div v-if="!loadingTable && paginatedTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
<div v-if="filteredTopics.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="filteredTopics.length" :page-size="pageSize" :current-page="currentPage" @current-change="currentPage = $event"></el-pagination>
</div>
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
<div class="topic-card-header">
@@ -138,19 +141,26 @@
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
<el-tag v-if="editing" type="warning" size="small">编辑模式</el-tag>
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
</div>
</div>
<div style="margin: 12px 0 16px; display: flex; justify-content: flex-end; gap: 8px;">
<div style="margin: 12px 0 16px; display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<el-button-group>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="switchPlatform('zhihu')">知乎</el-button>
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="switchPlatform('wechat')">微信公众号</el-button>
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="switchPlatform('xiaohongshu')">小红书</el-button>
</el-button-group>
<div>
<el-button v-if="!editing" size="small" type="primary" @click="startEdit" plain><el-icon style="vertical-align:-2px;"><IconEdit /></el-icon> 编辑</el-button>
<el-button v-if="editing" size="small" @click="cancelEdit">取消</el-button>
<el-button v-if="editing" size="small" type="primary" @click="saveContent" :loading="savingContent">保存</el-button>
</div>
</div>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe :srcdoc="currentPreviewHtml" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; display: flex; flex-direction: column;">
<div v-if="!editing" class="preview-body" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:20px; overflow:auto; width:100%; line-height:1.8;" v-html="currentPreviewBody"></div>
<div v-else ref="editor" contenteditable="true" style="flex:1; min-height:500px; border:1px solid #409eff; border-radius:8px; background:#fff; padding:20px; overflow: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>
<template #footer>
@@ -182,10 +192,11 @@ const TopicsApp = {
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [], allTopics: [], todayTopics: [], todayCount: 0,
previewVisible: false, previewTopic: null, previewFullscreen: false,
previewPlatform: 'zhihu', platformContents: {},
previewPlatform: 'zhihu', platformContents: {}, editing: false, editContent: '',
publishDialogVisible: false, publishTopic: null,
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false
publishing: false,
currentPage: 1, pageSize: 20
}
},
computed: {
@@ -196,6 +207,10 @@ const TopicsApp = {
const allowed = map[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
},
paginatedTopics() {
const start = (this.currentPage - 1) * this.pageSize;
return this.filteredTopics.slice(start, start + this.pageSize);
},
statusStats() {
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 };
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
@@ -217,6 +232,18 @@ const TopicsApp = {
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
} catch (e) { console.error('生成预览 HTML 失败:', e); return html; }
},
currentPreviewBody() {
const html = this.platformContents[this.previewPlatform];
if (!html) return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const body = doc.body;
if (!body) return html;
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
return body.innerHTML;
} catch (e) { console.error('解析 HTML 失败:', e); return html; }
},
selectAllLabel() {
const visible = this.filteredTopics.map(t => t.id);
if (visible.length === 0) return '全选';
@@ -234,6 +261,7 @@ const TopicsApp = {
return res.json();
},
async fetchTopics() {
this.currentPage = 1;
this.loadingTable = true;
try {
const token = this.getToken();
-2
View File
@@ -135,10 +135,8 @@
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'platforms', label: '平台', page: 'platforms.html' },
{ key: 'logs', label: '日志', page: 'logs.html' },
];
if (isAdmin) {
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
}
return items;
+9 -187
View File
@@ -3,197 +3,19 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 用户管理</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<title>宇之然 - 用户管理</title>
<style>
.user-table { width: 100%; }
.user-table .el-table__cell { word-break: break-word; }
.user-card-list { display: none; }
@media (max-width: 768px) {
.user-table { display: none; }
.user-card-list { display: block; margin: 0 -16px; }
.user-card { background: var(--color-bg-raised); border-radius: var(--radius-sm); padding: var(--spacing-md); margin-bottom: 12px; box-shadow: var(--shadow-sm); transition: all 0.2s ease; }
.user-card:active { transform: scale(0.99); }
.user-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.user-card-name { font-size: var(--font-size-h3); font-weight: 600; display: flex; align-items: center; gap: var(--spacing-sm); }
.user-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-sm); font-size: var(--font-size-small); color: var(--color-text-regular); margin-bottom: 12px; }
.user-card-meta-item { display: flex; flex-direction: column; gap: 2px; }
.user-card-meta-label { font-size: var(--font-size-caption); color: var(--color-text-secondary); }
.user-card-actions { display: flex; gap: var(--spacing-sm); justify-content: flex-end; padding-top: 12px; border-top: 1px solid var(--color-border); }
}
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; font-family: system-ui, sans-serif; }
.msg { text-align: center; padding: 20px; }
.msg h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
.msg p { font-size: 14px; opacity: 0.8; }
</style>
<script src="uni-nav.js"></script>
<script>window.location.href = '/admin.html#tab=users';</script>
</head>
<body>
<div id="app">
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="card-header">
<h2 class="card-title"><el-icon style="vertical-align:-2px;"><IconUser /></el-icon> 用户管理</h2>
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
</div>
<el-table :data="users" stripe class="user-table" v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!loading && users.length === 0" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:48px;margin-bottom:12px;color:#c0c4cc;"><IconUser /></el-icon>
<div>暂无用户</div>
</div>
<div class="user-card-list">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-card-header">
<div class="user-card-name">
{{ u.username }}
<el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag>
</div>
<span style="font-size: 12px; color: #909399;">#{{ u.id }}</span>
</div>
<div class="user-card-meta">
<div class="user-card-meta-item">
<span class="user-card-meta-label">组织</span>
<span>{{ u.org_id || '-' }}</span>
</div>
<div class="user-card-meta-item">
<span class="user-card-meta-label">创建时间</span>
<span>{{ formatDate(u.created_at) }}</span>
</div>
</div>
<div class="user-card-actions">
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
</div>
</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="dialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px" @submit.prevent="submitUser">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="form.password" type="password" placeholder="至少6位" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="submitting">创建</el-button>
</template>
</el-dialog>
<div class="msg">
<h2>用户管理已合并</h2>
<p>正在跳转到 系统管理 → 用户管理...</p>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const UsersApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
users: [], dialogVisible: false, submitting: false, loading: false,
form: { username: '', password: '', role: 'editor' }
}
},
methods: {
async fetchUsers() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
const response = await fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + token } });
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const data = await response.json();
this.users = data || [];
} catch (error) {
console.error('获取用户失败:', error);
this.$message.error(`获取用户失败: ${error.message}`);
this.users = [];
} finally { this.loading = false; }
},
addUser() {
this.form = { username: '', password: '', role: 'editor' };
this.dialogVisible = true;
},
async submitUser() {
if (!this.form.username || this.form.username.length < 2) { this.$message.warning('用户名至少2个字符'); return; }
if (!this.form.password || this.form.password.length < 6) { this.$message.warning('密码至少6位'); return; }
this.submitting = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(this.form)
});
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const newUser = await response.json();
this.users.push(newUser);
this.$message.success('添加用户成功');
this.dialogVisible = false;
} catch (error) {
console.error('添加用户失败:', error);
this.$message.error(`添加用户失败: ${error.message}`);
} finally {
this.submitting = false;
}
},
async deleteUser(id) {
if (id === 'admin') { this.$message.warning('不能删除管理员用户'); return; }
this.$confirm('确定删除该用户?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); window.location.href = '/'; return; }
const response = await fetch(`/api/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
this.users = this.users.filter(u => u.id !== id);
this.$message.success('删除用户成功');
} catch (error) { console.error('删除用户失败:', error); this.$message.error(`删除用户失败: ${error.message}`); }
})
.catch(() => {});
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/login.html'; } else { this.fetchUsers(); } })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
formatDate(dateStr) { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
},
mounted() { this.checkAuth(); }
};
const app = Vue.createApp(UsersApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>