新增用户管理/角色管理/菜单管理功能,修复创作流水线研究脚本

- 用户管理:新增编辑弹窗(修改用户名/角色/密码),增加组织/创建时间/最后登录列
- 角色管理:新增 Role 模型 + CRUD API,admin.html 新增角色管理 tab
- 菜单管理:新增 Menu 模型 + CRUD API,导航栏从 API 动态加载菜单项
- 个人中心:右上角下拉菜单(个人信息/修改密码/退出),新增修改密码 API
- 种子数据:initial_data.py 自动创建默认角色(admin/editor)和默认菜单(7项)
- 修复 research.py 缺少 enrich_topic_research 函数导致导入失败
- 修复 db_helper.py 中 generated_at 条件导致重创作不更新时间戳
- admin.html 操作列加宽防止按钮换行,平台配置增加删除按钮
- articles.html 预览弹窗加 lock-scroll=false 防止页面尺寸跳动
This commit is contained in:
Yuzhiran Dev
2026-05-22 18:34:27 +08:00
parent 1855f190f5
commit b2d043b231
14 changed files with 811 additions and 139 deletions
+118 -13
View File
@@ -153,10 +153,24 @@
},
emits: ['logout'],
data: function () {
return { dropdownOpen: false };
return {
dropdownOpen: false,
profileOpen: false,
showProfileDialog: false,
showPwdDialog: false,
profile: null,
pwdForm: { old_password: '', new_password: '', confirm: '' },
pwdSubmitting: false,
serverMenus: null,
};
},
computed: {
items: function () {
if (this.serverMenus) {
return this.serverMenus
.filter(function (m) { return m.is_active !== false; })
.map(function (m) { return { key: m.name, label: m.name, page: m.path }; });
}
return navItems(this.isAdmin);
},
page: function () {
@@ -167,31 +181,79 @@
},
},
methods: {
fetchMenus: function () {
var token = localStorage.getItem('authToken');
if (!token) return;
fetch('/api/menus/active', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (data) {
if (data && data.length > 0) this.serverMenus = data;
}.bind(this))
.catch(function () {});
},
navigate: function (page) {
this.dropdownOpen = false;
this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; }
window.location.href = page === '/' ? '/' : '/' + page;
},
logout: function () {
this.profileOpen = false;
this.$emit('logout');
},
toggleDropdown: function () {
this.dropdownOpen = !this.dropdownOpen;
this.dropdownOpen = !this.dropdownOpen; this.profileOpen = false;
},
closeDropdown: function (e) {
if (this.dropdownOpen && !this.$el.contains(e.target)) {
this.dropdownOpen = false;
}
toggleProfile: function () {
this.profileOpen = !this.profileOpen; this.dropdownOpen = false;
},
closeAll: function (e) {
var el = this.$el;
if (!el) return;
if (this.dropdownOpen && !el.contains(e.target)) this.dropdownOpen = false;
if (this.profileOpen && !el.querySelector('.uni-nav-profile-menu')?.contains(e.target) && e.target !== el.querySelector('.uni-nav-avatar') && !el.querySelector('.uni-nav-avatar')?.contains(e.target)) this.profileOpen = false;
},
openProfile: function () {
this.profileOpen = false;
var token = localStorage.getItem('authToken');
if (!token) return;
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) { this.profile = d.user || d; this.showProfileDialog = true; }.bind(this))
.catch(function () { (ElementPlus.ElMessage || {}).error && ElementPlus.ElMessage.error('获取用户信息失败'); });
},
openPwdDialog: function () {
this.profileOpen = false;
this.pwdForm = { old_password: '', new_password: '', confirm: '' };
this.showPwdDialog = true;
},
submitPwd: function () {
if (this.pwdForm.new_password.length < 6) { (ElementPlus.ElMessage || {}).warning && ElementPlus.ElMessage.warning('新密码至少6位'); return; }
if (this.pwdForm.new_password !== this.pwdForm.confirm) { (ElementPlus.ElMessage || {}).warning && ElementPlus.ElMessage.warning('两次密码不一致'); return; }
this.pwdSubmitting = true;
var token = localStorage.getItem('authToken');
fetch('/api/auth/password', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ old_password: this.pwdForm.old_password, new_password: this.pwdForm.new_password })
}).then(function (r) {
if (r.ok) { this.showPwdDialog = false; (ElementPlus.ElMessage || {}).success && ElementPlus.ElMessage.success('密码修改成功'); }
else { return r.json().then(function (d) { throw new Error(d.detail || '修改失败'); }); }
}.bind(this)).catch(function (e) {
(ElementPlus.ElMessage || {}).error && ElementPlus.ElMessage.error(e.message);
}).finally(function () { this.pwdSubmitting = false; }.bind(this));
},
closePwdDialog: function () { this.showPwdDialog = false; },
closeProfileDialog: function () { this.showProfileDialog = false; },
},
mounted: function () {
document.addEventListener('click', this.closeDropdown);
document.addEventListener('click', this.closeAll);
this.fetchMenus();
},
beforeUnmount: function () {
document.removeEventListener('click', this.closeDropdown);
document.removeEventListener('click', this.closeAll);
},
template: '\
<nav class="uni-nav">\
<nav class="uni-nav" style="position:relative;">\
<div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\
<div class="uni-nav-items">\
@@ -203,12 +265,22 @@
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
{{ dropdownOpen ? "✕" : "☰" }}\
</button>\
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
<span v-if="username" class="uni-nav-username">{{ username }}</span>\
<div class="uni-nav-avatar" style="cursor:pointer;" @click.stop="toggleProfile">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
<span v-if="username" class="uni-nav-username" style="cursor:pointer;" @click.stop="toggleProfile">{{ username }}</span>\
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
<button class="uni-nav-logout" @click="logout">退出</button>\
</div>\
</div>\
<div v-if="profileOpen" class="uni-nav-profile-menu" @click.stop\
style="position:absolute;top:60px;right:16px;background:#fff;box-shadow:0 4px 16px rgba(0,0,0,0.12);border-radius:8px;z-index:10001;min-width:160px;padding:4px;">\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#303133;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="openProfile">📋 个人信息</button>\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#303133;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="openPwdDialog">🔑 修改密码</button>\
<div style="height:1px;background:#f0f0f0;margin:4px 0;"></div>\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#e74c3c;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="logout">🚪 退出登录</button>\
</div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
@@ -216,7 +288,40 @@
{{ item.label }}\
</button>\
</div>\
</nav>',
</nav>\
<div v-if="showProfileDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;display:flex;align-items:center;justify-content:center;" @click.self="closeProfileDialog">\
<div style="background:#fff;border-radius:12px;width:380px;max-width:90vw;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,0.2);">\
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133;">个人信息</h3>\
<div style="display:flex;flex-direction:column;gap:10px;">\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">用户名</span><span style="color:#303133;">{{ profile ? profile.username : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">角色</span><span style="color:#303133;">{{ profile ? profile.role : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">组织</span><span style="color:#303133;">{{ profile ? (profile.org_id || \'-\') : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">创建时间</span><span style="color:#303133;">{{ profile && profile.created_at ? new Date(profile.created_at).toLocaleString(\'zh-CN\') : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">最后登录</span><span style="color:#303133;">{{ profile && profile.last_login ? new Date(profile.last_login).toLocaleString(\'zh-CN\') : \'-\' }}</span></div>\
</div>\
<div style="margin-top:16px;text-align:right;"><button style="padding:6px 16px;border:1px solid #dcdfe6;background:#fff;color:#606266;border-radius:6px;cursor:pointer;font-size:13px;" @click="closeProfileDialog">关闭</button></div>\
</div>\
</div>\
<div v-if="showPwdDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;display:flex;align-items:center;justify-content:center;" @click.self="closePwdDialog">\
<div style="background:#fff;border-radius:12px;width:380px;max-width:90vw;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,0.2);">\
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133;">修改密码</h3>\
<div style="display:flex;flex-direction:column;gap:12px;">\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">原密码</label>\
<input v-model="pwdForm.old_password" type="password" placeholder="输入原密码"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">新密码</label>\
<input v-model="pwdForm.new_password" type="password" placeholder="至少6位"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">确认新密码</label>\
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
</div>\
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">\
<button style="padding:6px 16px;border:1px solid #dcdfe6;background:#fff;color:#606266;border-radius:6px;cursor:pointer;font-size:13px;" @click="closePwdDialog">取消</button>\
<button style="padding:6px 16px;border:none;background:#409eff;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;" @click="submitPwd" :disabled="pwdSubmitting">{{ pwdSubmitting ? "提交中..." : "确定" }}</button>\
</div>\
</div>\
</div>',
};
window.UniNav = UniNav;