Add pagination to admin management tabs (cases/categories/sources/orgs)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
from fastapi import APIRouter, HTTPException, Query, Depends, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -140,6 +140,31 @@ def preview_article(topic_id: str, platform: str = "zhihu", current_user: User =
|
|||||||
raise HTTPException(status_code=404, detail=f"Article not found for {topic_id} on {platform}")
|
raise HTTPException(status_code=404, detail=f"Article not found for {topic_id} on {platform}")
|
||||||
return {"topic_id": topic_id, "platform": platform, "html": article.html_content}
|
return {"topic_id": topic_id, "platform": platform, "html": article.html_content}
|
||||||
|
|
||||||
|
@router.put("/{topic_id}/content")
|
||||||
|
def update_article_content(
|
||||||
|
topic_id: str,
|
||||||
|
platform: str = Body(...),
|
||||||
|
html_content: str = Body(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""保存用户编辑后的文章 HTML 内容"""
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
article_id = f"{platform}_{topic_id}"
|
||||||
|
article = db.query(Article).filter(Article.id == article_id).first()
|
||||||
|
if not article:
|
||||||
|
article = Article(
|
||||||
|
id=article_id, topic_id=topic_id, platform=platform,
|
||||||
|
file_path=f"db:{article_id}", status="draft"
|
||||||
|
)
|
||||||
|
db.add(article)
|
||||||
|
article.html_content = html_content
|
||||||
|
article.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "id": article_id}
|
||||||
|
|
||||||
@router.get("/optimization-report")
|
@router.get("/optimization-report")
|
||||||
def get_optimization_report(publish_date: str = None, current_user: User = Depends(get_current_user)):
|
def get_optimization_report(publish_date: str = None, current_user: User = Depends(get_current_user)):
|
||||||
"""获取合规优化报告"""
|
"""获取合规优化报告"""
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ def get_modules_status():
|
|||||||
today_str = date.today().isoformat()
|
today_str = date.today().isoformat()
|
||||||
log_based: dict = {
|
log_based: dict = {
|
||||||
"scheduled_collect": {"name": "📡 内容采集", "log": LOGS_DIR / f"collector_{today_str}.log"},
|
"scheduled_collect": {"name": "📡 内容采集", "log": LOGS_DIR / f"collector_{today_str}.log"},
|
||||||
"scheduled_sync": {"name": "🔄 指标同步", "log": LOGS_DIR / f"sync_{today_str}.log"},
|
"scheduled_sync": {"name": "🔄 数据同步", "log": LOGS_DIR / f"sync_{today_str}.log"},
|
||||||
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
|
"scheduled_generate": {"name": "🤖 内容创作", "log": LOGS_DIR / f"creator_{today_str}.log"},
|
||||||
"scheduled_optimize": {"name": "🔍 内容优化", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
|
"scheduled_optimize": {"name": "🔍 内容优化", "log": LOGS_DIR / f"optimizer_{today_str}.log"},
|
||||||
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
|
"scheduled_optimize_sources": {"name": "📡 信息源优化", "log": LOGS_DIR / f"optimizer_sources_{today_str}.log"},
|
||||||
@@ -207,7 +207,7 @@ def get_modules_status():
|
|||||||
task_count = content.count("完成") + content.count("success") + content.count("SUCCESS")
|
task_count = content.count("完成") + content.count("success") + content.count("SUCCESS")
|
||||||
total = task_count + content.count("失败") + content.count("failed") + content.count("ERROR")
|
total = task_count + content.count("失败") + content.count("failed") + content.count("ERROR")
|
||||||
success_rate = round(task_count / total * 100) if total > 0 else None
|
success_rate = round(task_count / total * 100) if total > 0 else None
|
||||||
status = "running" if log_file.exists() else ("idle" if mod_id in jobs else "stopped")
|
status = "running" if mod_id in jobs else "stopped"
|
||||||
modules.append({
|
modules.append({
|
||||||
"id": mod_id,
|
"id": mod_id,
|
||||||
"title": cfg["name"],
|
"title": cfg["name"],
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ def list_tasks(
|
|||||||
topic_id: Optional[str] = None,
|
topic_id: Optional[str] = None,
|
||||||
stage: Optional[str] = None,
|
stage: Optional[str] = None,
|
||||||
limit: int = Query(50, le=200),
|
limit: int = Query(50, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(get_current_user)
|
current_user=Depends(get_current_user)
|
||||||
):
|
):
|
||||||
query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
query = db.query(ContentTask, Topic.title.label("topic_title")).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
|
||||||
of = org_filter(current_user, Topic)
|
of = org_filter(current_user, Topic)
|
||||||
if of is not True:
|
if of is not True:
|
||||||
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
|
||||||
@@ -30,7 +31,13 @@ def list_tasks(
|
|||||||
query = query.filter(ContentTask.topic_id == topic_id)
|
query = query.filter(ContentTask.topic_id == topic_id)
|
||||||
if stage:
|
if stage:
|
||||||
query = query.filter(ContentTask.stage == stage)
|
query = query.filter(ContentTask.stage == stage)
|
||||||
return query.order_by(ContentTask.created_at.desc()).limit(limit).all()
|
rows = query.order_by(ContentTask.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
task = r.ContentTask
|
||||||
|
task.topic_title = r.topic_title
|
||||||
|
result.append(task)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/active")
|
@router.get("/active")
|
||||||
|
|||||||
@@ -319,6 +319,8 @@ class ContentTaskCreate(BaseModel):
|
|||||||
class ContentTaskResponse(ContentTaskBase):
|
class ContentTaskResponse(ContentTaskBase):
|
||||||
id: int
|
id: int
|
||||||
task_id: str
|
task_id: str
|
||||||
|
topic_id: Optional[str] = None
|
||||||
|
topic_title: Optional[str] = None
|
||||||
result_data: Dict[str, Any] = {}
|
result_data: Dict[str, Any] = {}
|
||||||
error_msg: Optional[str] = None
|
error_msg: Optional[str] = None
|
||||||
started_at: Optional[datetime] = None
|
started_at: Optional[datetime] = None
|
||||||
|
|||||||
+202
-11
@@ -26,9 +26,11 @@
|
|||||||
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</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 === '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 === '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 === '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 === '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 === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
|
||||||
|
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
<div class="stat-summary">
|
<div class="stat-summary">
|
||||||
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
|
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
|
||||||
</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="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="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="field" label="领域" width="100"></el-table-column>
|
||||||
@@ -61,9 +63,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</template>
|
||||||
<div class="card-list-mobile">
|
<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.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.field }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</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>
|
</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 v-if="activeTab === 'categories'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
|
||||||
@@ -212,7 +282,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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="id" label="ID" width="60"></el-table-column>
|
||||||
<el-table-column prop="name" label="名称" min-width="120"></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>
|
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
|
||||||
@@ -230,9 +300,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</template>
|
||||||
<div class="card-list-mobile" v-if="categories.length > 0">
|
<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.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.search_query }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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="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="name" label="名称" min-width="150"></el-table-column>
|
||||||
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
|
||||||
@@ -277,9 +350,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</template>
|
||||||
<div class="card-list-mobile" v-if="orgs.length > 0">
|
<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">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.name }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">用户</span><span class="card-value">{{ item.user_count }}</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>
|
</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 v-if="activeTab === 'sources'">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
|
||||||
@@ -307,7 +409,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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="id" label="ID" width="50"></el-table-column>
|
||||||
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
|
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
|
||||||
<el-table-column prop="source_type" label="类型" width="90">
|
<el-table-column prop="source_type" label="类型" width="90">
|
||||||
@@ -328,9 +430,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</template>
|
||||||
<div class="card-list-mobile" v-if="sources.length > 0">
|
<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.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.source_type }}</span></div>
|
||||||
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</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="icon-components.js"></script>
|
||||||
<script src="element-plus.full.js"></script>
|
<script src="element-plus.full.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, reactive, onMounted } = Vue;
|
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||||
const { ElMessage, ElMessageBox } = ElementPlus;
|
const { ElMessage, ElMessageBox } = ElementPlus;
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
@@ -513,7 +618,7 @@
|
|||||||
})
|
})
|
||||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/login.html'; });
|
.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 cases = ref([]);
|
||||||
const casesLoading = ref(false);
|
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(); }
|
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); }
|
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 taskLogs = ref([]);
|
||||||
const taskLogsLoading = ref(false);
|
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(); }
|
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); }
|
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 () => {
|
const loadSystemConfigs = async () => {
|
||||||
systemConfigsLoading.value = true;
|
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(); }
|
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); }
|
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 () => {
|
const loadSources = async () => {
|
||||||
sourcesLoading.value = true;
|
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(); }
|
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); }
|
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 logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||||
|
|
||||||
const tabLoaders = {
|
const tabLoaders = {
|
||||||
cases: loadCases, tasklogs: loadTaskLogs,
|
cases: loadCases, tasklogs: loadTaskLogs,
|
||||||
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
|
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
|
||||||
|
users: fetchUsers,
|
||||||
categories: loadCategories, sources: loadSources,
|
categories: loadCategories, sources: loadSources,
|
||||||
orgs: loadOrgs,
|
orgs: loadOrgs,
|
||||||
};
|
};
|
||||||
@@ -730,18 +912,27 @@
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadedTabs.add('cases');
|
loadedTabs.add('cases');
|
||||||
loadCases();
|
loadCases();
|
||||||
|
const hash = window.location.hash.replace('#tab=', '');
|
||||||
|
if (hash && tabLoaders[hash]) { switchTab(hash); }
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTab, switchTab,
|
activeTab, switchTab,
|
||||||
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
cases, casesLoading, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
||||||
|
casePage, casePageSize, paginatedCases,
|
||||||
taskLogs, taskLogsLoading, loadTaskLogs,
|
taskLogs, taskLogsLoading, loadTaskLogs,
|
||||||
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||||
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||||
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
|
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
|
||||||
|
categoryPage, categoryPageSize, paginatedCategories,
|
||||||
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
|
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
|
||||||
|
sourcePage, sourcePageSize, paginatedSources,
|
||||||
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
.day-holiday { font-size: 8px; padding: 0 2px; }
|
.day-holiday { font-size: 8px; padding: 0 2px; }
|
||||||
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
|
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
|
||||||
.stats-bar { gap: 12px; font-size: 12px; }
|
.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; }
|
.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.published { background:#f0f9eb; color:#67c23a; }
|
||||||
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
|
.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; }
|
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||||
.stat-item { display: flex; align-items: center; gap: 8px; }
|
.stat-item { display: flex; align-items: center; gap: 8px; }
|
||||||
.stat-dot { width: 10px; height: 10px; border-radius: 50%; }
|
.stat-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
@@ -78,8 +103,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 class="stats-bar">
|
||||||
<div v-if="loadingCalendar" style="color:#909399;font-size:14px;">加载中...</div>
|
<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">
|
<div v-else-if="!calendarDays.length" class="empty-state">
|
||||||
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
|
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
|
||||||
<div class="empty-text">暂无日历数据</div>
|
<div class="empty-text">暂无日历数据</div>
|
||||||
@@ -113,15 +172,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="calendar-hint">💡 点击日期查看或添加日程 · 点击日程卡片编辑详情</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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">
|
<div v-if="selectedDay">
|
||||||
<el-button type="primary" @click="openCreateDialog" style="margin-bottom: 16px;">+ 添加日程</el-button>
|
<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="title" label="标题" min-width="120"></el-table-column>
|
||||||
<el-table-column prop="platform" label="平台" width="100">
|
<el-table-column prop="platform" label="平台" width="100">
|
||||||
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
|
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
|
||||||
@@ -321,11 +386,28 @@ function getDayMeta(year, month, day) {
|
|||||||
|
|
||||||
const loadingCalendar = ref(false);
|
const loadingCalendar = ref(false);
|
||||||
const calendarError = ref('');
|
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 () => {
|
const fetchEntries = async () => {
|
||||||
loadingCalendar.value = true;
|
loadingCalendar.value = true;
|
||||||
calendarError.value = '';
|
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; }
|
finally { loadingCalendar.value = false; }
|
||||||
};
|
};
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
@@ -398,7 +480,7 @@ function getDayMeta(year, month, day) {
|
|||||||
checkAuth();
|
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 };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
|
||||||
.module-content div:last-child { border-bottom: none; }
|
.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; }
|
.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; } }
|
@media (max-width: 768px) { .module-grid { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -102,6 +107,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +156,9 @@
|
|||||||
},
|
},
|
||||||
modules: [],
|
modules: [],
|
||||||
schedulerStatus: '',
|
schedulerStatus: '',
|
||||||
schedulerRunning: false
|
schedulerRunning: false,
|
||||||
|
upcomingEntries: [],
|
||||||
|
loadingPlan: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -221,6 +248,30 @@
|
|||||||
this.$message.error('获取模块状态失败: ' + e.message);
|
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) {
|
goToTopics(filter) {
|
||||||
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
|
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
@@ -246,6 +297,7 @@
|
|||||||
this.currentPage = 'overview';
|
this.currentPage = 'overview';
|
||||||
this.fetchStats();
|
this.fetchStats();
|
||||||
this.fetchModules();
|
this.fetchModules();
|
||||||
|
this.fetchUpcomingPlan();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('authToken');
|
localStorage.removeItem('authToken');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="stylesheet" href="element-plus.css">
|
<link rel="stylesheet" href="element-plus.css">
|
||||||
<link rel="stylesheet" href="theme-modern.css">
|
<link rel="stylesheet" href="theme-modern.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="login-body">
|
||||||
<!-- 背景装饰 -->
|
<!-- 背景装饰 -->
|
||||||
<div class="bg-circle"></div>
|
<div class="bg-circle"></div>
|
||||||
<div class="bg-circle"></div>
|
<div class="bg-circle"></div>
|
||||||
|
|||||||
@@ -3,107 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>宇之然内容创作平台 - 系统日志</title>
|
<title>宇之然 - 运行日志</title>
|
||||||
<link rel="stylesheet" href="element-plus.css">
|
|
||||||
<link rel="stylesheet" href="theme-modern.css">
|
|
||||||
<style>
|
<style>
|
||||||
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
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; }
|
||||||
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
|
.msg { text-align: center; padding: 20px; }
|
||||||
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
|
.msg h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
|
||||||
|
.msg p { font-size: 14px; opacity: 0.8; }
|
||||||
@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; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<script src="uni-nav.js"></script>
|
<script>window.location.href = '/admin.html#tab=logs';</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div class="msg">
|
||||||
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
|
<h2>运行日志已合并</h2>
|
||||||
</uni-nav>
|
<p>正在跳转到 系统管理 → 运行日志...</p>
|
||||||
<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>
|
||||||
<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>
|
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -51,24 +51,6 @@
|
|||||||
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
|
||||||
<div>加载中...</div>
|
<div>加载中...</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">
|
<div class="card page-fade">
|
||||||
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 选题状态分布</h3>
|
<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;">
|
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items:center;">
|
||||||
|
|||||||
@@ -75,12 +75,16 @@
|
|||||||
<div class="schedule-info">
|
<div class="schedule-info">
|
||||||
<div class="schedule-name">{{ job.name }}</div>
|
<div class="schedule-name">{{ job.name }}</div>
|
||||||
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</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>
|
||||||
<div class="schedule-next">
|
<div class="schedule-next">
|
||||||
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
|
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
|
||||||
<span>{{ job.active ? '运行中' : '等待中' }}</span>
|
<span>{{ job.active ? '运行中' : '等待中' }}</span>
|
||||||
<span v-if="job.next_run" style="margin-left: 8px; color: #909399;">下次: {{ job.next_run }}</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +113,8 @@
|
|||||||
<div v-for="task in tasks" :key="task.task_id" class="task-card">
|
<div v-for="task in tasks" :key="task.task_id" class="task-card">
|
||||||
<div class="task-header">
|
<div class="task-header">
|
||||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
<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="status-badge" :class="'status-' + task.status">{{ getStatusLabel(task.status) }}</span>
|
||||||
<span class="task-stage" :class="'stage-' + task.stage">{{ getStageLabel(task.stage) }}</span>
|
<span class="task-stage" :class="'stage-' + task.stage">{{ getStageLabel(task.stage) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-meta">
|
<div class="task-meta">
|
||||||
|
<span>ID: {{ task.task_id }}</span>
|
||||||
<span>创建: {{ formatDate(task.created_at) }}</span>
|
<span>创建: {{ formatDate(task.created_at) }}</span>
|
||||||
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
|
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
|
||||||
<span v-if="task.finished_at">完成: {{ formatDate(task.finished_at) }}</span>
|
<span v-if="task.finished_at">完成: {{ formatDate(task.finished_at) }}</span>
|
||||||
@@ -166,15 +172,21 @@
|
|||||||
const TasksApp = {
|
const TasksApp = {
|
||||||
data() {
|
data() {
|
||||||
const SCHEDULER_JOBS = {
|
const SCHEDULER_JOBS = {
|
||||||
|
'scheduled_collect': { icon: 'IconRefresh', name: '内容采集', defaultTime: '01:30' },
|
||||||
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
|
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
|
||||||
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
|
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
|
||||||
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04: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 {
|
return {
|
||||||
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
|
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
|
||||||
tasks: [], loading: false, filterStatus: '',
|
tasks: [], loading: false, filterStatus: '',
|
||||||
showDetailDialog: false, detailTask: null,
|
showDetailDialog: false, detailTask: null,
|
||||||
schedulerJobs: [], schedulerLoading: false,
|
schedulerJobs: [], schedulerLoading: false,
|
||||||
|
totalTasks: 0, currentPage: 1, pageSize: 20,
|
||||||
SCHEDULER_JOBS,
|
SCHEDULER_JOBS,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -227,9 +239,11 @@ const TasksApp = {
|
|||||||
async loadTasks() {
|
async loadTasks() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
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;
|
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); }
|
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
|
||||||
finally { this.loading = false; }
|
finally { this.loading = false; }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 === '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>
|
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
|
||||||
</div>
|
</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 type="selection" width="55"></el-table-column>
|
||||||
<el-table-column prop="id" label="ID" width="70" fixed></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>
|
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||||
@@ -76,7 +76,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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 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 v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
|
||||||
<div class="topic-card-header">
|
<div class="topic-card-header">
|
||||||
@@ -138,19 +141,26 @@
|
|||||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</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 size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
|
||||||
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
|
||||||
</div>
|
</div>
|
||||||
</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-group>
|
||||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="switchPlatform('zhihu')">知乎</el-button>
|
||||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="switchPlatform('wechat')">微信公众号</el-button>
|
||||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="switchPlatform('xiaohongshu')">小红书</el-button>
|
||||||
</el-button-group>
|
</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;">
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -182,10 +192,11 @@ const TopicsApp = {
|
|||||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||||
topics: [], allTopics: [], todayTopics: [], todayCount: 0,
|
topics: [], allTopics: [], todayTopics: [], todayCount: 0,
|
||||||
previewVisible: false, previewTopic: null, previewFullscreen: false,
|
previewVisible: false, previewTopic: null, previewFullscreen: false,
|
||||||
previewPlatform: 'zhihu', platformContents: {},
|
previewPlatform: 'zhihu', platformContents: {}, editing: false, editContent: '',
|
||||||
publishDialogVisible: false, publishTopic: null,
|
publishDialogVisible: false, publishTopic: null,
|
||||||
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
|
||||||
publishing: false
|
publishing: false,
|
||||||
|
currentPage: 1, pageSize: 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -196,6 +207,10 @@ const TopicsApp = {
|
|||||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||||
return this.topics.filter(t => allowed.includes(t.status));
|
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() {
|
statusStats() {
|
||||||
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 };
|
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 };
|
||||||
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
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>`;
|
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; }
|
} 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() {
|
selectAllLabel() {
|
||||||
const visible = this.filteredTopics.map(t => t.id);
|
const visible = this.filteredTopics.map(t => t.id);
|
||||||
if (visible.length === 0) return '全选';
|
if (visible.length === 0) return '全选';
|
||||||
@@ -234,6 +261,7 @@ const TopicsApp = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
async fetchTopics() {
|
async fetchTopics() {
|
||||||
|
this.currentPage = 1;
|
||||||
this.loadingTable = true;
|
this.loadingTable = true;
|
||||||
try {
|
try {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
|
|||||||
@@ -135,10 +135,8 @@
|
|||||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||||
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
{ key: 'platforms', label: '平台', page: 'platforms.html' },
|
||||||
{ key: 'logs', label: '日志', page: 'logs.html' },
|
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
|
|
||||||
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -3,197 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>宇之然内容创作平台 - 用户管理</title>
|
<title>宇之然 - 用户管理</title>
|
||||||
<link rel="stylesheet" href="element-plus.css">
|
|
||||||
<link rel="stylesheet" href="theme-modern.css">
|
|
||||||
<style>
|
<style>
|
||||||
.user-table { width: 100%; }
|
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; }
|
||||||
.user-table .el-table__cell { word-break: break-word; }
|
.msg { text-align: center; padding: 20px; }
|
||||||
|
.msg h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
|
||||||
.user-card-list { display: none; }
|
.msg p { font-size: 14px; opacity: 0.8; }
|
||||||
|
|
||||||
@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); }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
<script src="uni-nav.js"></script>
|
<script>window.location.href = '/admin.html#tab=users';</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div class="msg">
|
||||||
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
|
<h2>用户管理已合并</h2>
|
||||||
</uni-nav>
|
<p>正在跳转到 系统管理 → 用户管理...</p>
|
||||||
<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>
|
</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>
|
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user