feat: Multi-user system with PostgreSQL - WIP temporary save

This commit is contained in:
TIANHE
2026-01-14 05:29:55 +08:00
parent 996e3b38fe
commit 61a5e5e6aa
68 changed files with 91057 additions and 1920 deletions
+32 -16
View File
@@ -1,4 +1,4 @@
import request from '@/utils/request'
import request, { ANALYSIS_TIMEOUT } from '@/utils/request'
const marketApi = {
// Watchlist
@@ -11,6 +11,7 @@ const marketApi = {
CreateAnalysisTask: '/api/analysis/createTask',
GetAnalysisTaskStatus: '/api/analysis/getTaskStatus',
GetAnalysisHistoryList: '/api/analysis/getHistoryList',
DeleteAnalysisTask: '/api/analysis/deleteTask',
ReflectAnalysis: '/api/analysis/reflect',
// AI chat (optional)
ChatMessage: '/api/ai/chat/message',
@@ -34,8 +35,8 @@ const marketApi = {
export function getWatchlist (parameter) {
return request({
url: marketApi.GetWatchlist,
method: 'post',
data: parameter
method: 'get',
params: parameter
})
}
@@ -73,8 +74,10 @@ export function removeWatchlist (parameter) {
export function getWatchlistPrices (parameter) {
return request({
url: marketApi.GetWatchlistPrices,
method: 'post',
data: parameter
method: 'get',
params: {
watchlist: JSON.stringify(parameter.watchlist || [])
}
})
}
@@ -99,8 +102,8 @@ export function chatMessage (parameter) {
export function getChatHistory (parameter) {
return request({
url: marketApi.GetChatHistory,
method: 'post',
data: parameter
method: 'get',
params: parameter
})
}
@@ -126,7 +129,8 @@ export function multiAnalysis (parameter) {
return request({
url: marketApi.MultiAnalysis,
method: 'post',
data: parameter
data: parameter,
timeout: ANALYSIS_TIMEOUT // Extended timeout for AI analysis
})
}
@@ -151,8 +155,8 @@ export function createAnalysisTask (parameter) {
export function getAnalysisTaskStatus (parameter) {
return request({
url: marketApi.GetAnalysisTaskStatus,
method: 'post',
data: parameter
method: 'get',
params: parameter
})
}
@@ -164,6 +168,19 @@ export function getAnalysisTaskStatus (parameter) {
export function getAnalysisHistoryList (parameter) {
return request({
url: marketApi.GetAnalysisHistoryList,
method: 'get',
params: parameter
})
}
/**
* Delete analysis task
* @param parameter { task_id: number }
* @returns {*}
*/
export function deleteAnalysisTask (parameter) {
return request({
url: marketApi.DeleteAnalysisTask,
method: 'post',
data: parameter
})
@@ -200,8 +217,7 @@ export function getConfig () {
export function getMenuFooterConfig () {
return request({
url: marketApi.GetMenuFooterConfig,
method: 'post',
data: {}
method: 'get'
})
}
@@ -224,8 +240,8 @@ export function getMarketTypes () {
export function searchSymbols (parameter) {
return request({
url: marketApi.SearchSymbols,
method: 'post',
data: parameter
method: 'get',
params: parameter
})
}
@@ -237,7 +253,7 @@ export function searchSymbols (parameter) {
export function getHotSymbols (parameter) {
return request({
url: marketApi.GetHotSymbols,
method: 'post',
data: parameter
method: 'get',
params: parameter
})
}
+126
View File
@@ -0,0 +1,126 @@
/**
* User Management API
*/
import request from '@/utils/request'
// ==================== Admin APIs ====================
/**
* Get user list (admin only)
* @param {Object} params - { page, page_size }
*/
export function getUserList (params) {
return request({
url: '/api/users/list',
method: 'get',
params
})
}
/**
* Get user detail (admin only)
* @param {Number} id - User ID
*/
export function getUserDetail (id) {
return request({
url: '/api/users/detail',
method: 'get',
params: { id }
})
}
/**
* Create new user (admin only)
* @param {Object} data - { username, password, email, nickname, role }
*/
export function createUser (data) {
return request({
url: '/api/users/create',
method: 'post',
data
})
}
/**
* Update user (admin only)
* @param {Number} id - User ID
* @param {Object} data - { email, nickname, role, status }
*/
export function updateUser (id, data) {
return request({
url: '/api/users/update',
method: 'put',
params: { id },
data
})
}
/**
* Delete user (admin only)
* @param {Number} id - User ID
*/
export function deleteUser (id) {
return request({
url: '/api/users/delete',
method: 'delete',
params: { id }
})
}
/**
* Reset user password (admin only)
* @param {Object} data - { user_id, new_password }
*/
export function resetUserPassword (data) {
return request({
url: '/api/users/reset-password',
method: 'post',
data
})
}
/**
* Get available roles
*/
export function getRoles () {
return request({
url: '/api/users/roles',
method: 'get'
})
}
// ==================== Self-Service APIs ====================
/**
* Get current user profile
*/
export function getProfile () {
return request({
url: '/api/users/profile',
method: 'get'
})
}
/**
* Update current user profile
* @param {Object} data - { nickname, email, avatar }
*/
export function updateProfile (data) {
return request({
url: '/api/users/profile/update',
method: 'put',
data
})
}
/**
* Change current user password
* @param {Object} data - { old_password, new_password }
*/
export function changePassword (data) {
return request({
url: '/api/users/change-password',
method: 'post',
data
})
}
@@ -6,6 +6,11 @@
</span>
<template v-slot:overlay>
<a-menu class="ant-pro-drop-down menu" :selected-keys="[]">
<a-menu-item key="profile" @click="handleProfile">
<a-icon type="user" />
{{ $t('menu.profile') || 'My Profile' }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="handleLogout">
<a-icon type="logout" />
{{ $t('menu.account.logout') }}
@@ -34,6 +39,9 @@ export default {
}
},
methods: {
handleProfile () {
this.$router.push({ name: 'Profile' })
},
handleLogout (e) {
Modal.confirm({
title: this.$t('layouts.usermenu.dialog.title'),
@@ -1,6 +1,6 @@
<template>
<div :class="wrpCls">
<!-- User avatar/name removed for local OSS build -->
<avatar-dropdown :menu="true" :current-user="currentUser" :class="prefixCls" />
<notice-icon :class="prefixCls" />
<select-lang :class="prefixCls" />
<a-tooltip :title="$t('app.setting.tooltip')">
@@ -12,12 +12,15 @@
</template>
<script>
import AvatarDropdown from './AvatarDropdown'
import SelectLang from '@/components/SelectLang'
import NoticeIcon from '@/components/NoticeIcon'
import { mapGetters } from 'vuex'
export default {
name: 'RightContent',
components: {
AvatarDropdown,
SelectLang,
NoticeIcon
},
@@ -51,6 +54,13 @@ export default {
}
},
computed: {
...mapGetters(['nickname', 'avatar']),
currentUser () {
return {
name: this.nickname,
avatar: this.avatar
}
},
wrpCls () {
return {
'ant-pro-global-header-index-right': true,
+16 -2
View File
@@ -51,12 +51,26 @@ export const asyncRouterMap = [
component: () => import('@/views/indicator-community'),
meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] }
},
// 系统设置
// 系统设置 (admin only)
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/settings'),
meta: { title: 'menu.settings', keepAlive: false, icon: 'setting', permission: ['dashboard'] }
meta: { title: 'menu.settings', keepAlive: false, icon: 'setting', permission: ['admin'] }
},
// 用户管理 (admin only)
{
path: '/user-manage',
name: 'UserManage',
component: () => import('@/views/user-manage'),
meta: { title: 'menu.userManage', keepAlive: false, icon: 'team', permission: ['admin'] }
},
// 个人中心
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/profile'),
meta: { title: 'menu.myProfile', keepAlive: false, icon: 'user', permission: ['dashboard'] }
}
// other
+11 -8
View File
@@ -162,8 +162,7 @@ export default {
// end
isDev: process.env.NODE_ENV === 'development' || process.env.VUE_APP_PREVIEW === 'true',
// base
menus: [],
// base - menus moved to computed property
// 侧栏收起状态
collapsed: false,
title: defaultSettings.title,
@@ -200,16 +199,16 @@ export default {
// Static footer config (local OSS build)
menuFooterConfig: {
contact: {
support_url: 'https://t.me/worldinbroker',
support_url: 'https://t.me/quantdinger',
feature_request_url: 'https://github.com/brokermr810/QuantDinger/issues',
email: 'brokermr810@gmail.com',
live_chat_url: 'https://t.me/worldinbroker'
live_chat_url: 'https://t.me/quantdinger'
},
social_accounts: [
{ name: 'GitHub', icon: 'github', url: 'https://github.com/brokermr810/QuantDinger' },
{ name: 'X', icon: 'x', url: 'https://x.com/HenryCryption' },
{ name: 'Discord', icon: 'discord', url: 'https://discord.gg/cn6HVE2KC' },
{ name: 'Telegram', icon: 'telegram', url: 'https://t.me/worldinbroker' },
{ name: 'Telegram', icon: 'telegram', url: 'https://t.me/quantdinger' },
{ name: 'YouTube', icon: 'youtube', url: 'https://youtube.com/@quantdinger' }
],
legal: {
@@ -226,11 +225,15 @@ export default {
...mapState({
// 动态主路由
mainMenu: state => state.permission.addRouters
})
}),
// 响应式菜单 - 根据 addRouters 动态更新
menus () {
const routes = this.mainMenu.find(item => item.path === '/')
return (routes && routes.children) || []
}
},
created () {
const routes = this.mainMenu.find(item => item.path === '/')
this.menus = (routes && routes.children) || []
// menus is now a computed property - no need to set here
// 从 store 同步主题设置(从 localStorage 恢复)
this.settings.theme = this.$store.state.app.theme
this.settings.primaryColor = this.$store.state.app.color || defaultSettings.primaryColor
@@ -443,6 +443,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': 'فشلت الإضافة',
'dashboard.analysis.message.removeStockSuccess': 'تمت الإزالة بنجاح',
'dashboard.analysis.message.removeStockFailed': 'فشلت عملية الإزالة',
'dashboard.analysis.message.resumingAnalysis': 'جاري استئناف مهمة التحليل...',
'dashboard.analysis.message.deleteSuccess': 'تم الحذف بنجاح',
'dashboard.analysis.message.deleteFailed': 'فشل الحذف',
'dashboard.analysis.modal.history.delete': 'حذف',
'dashboard.analysis.modal.history.deleteConfirm': 'هل أنت متأكد أنك تريد حذف سجل التحليل هذا؟',
'dashboard.analysis.test': 'متجر رقم {no}، طريق Gongzhuan',
'dashboard.analysis.introduce': 'وصف المؤشر',
'dashboard.analysis.total-sales': 'إجمالي المبيعات',
@@ -444,6 +444,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': 'Das Hinzufügen ist fehlgeschlagen',
'dashboard.analysis.message.removeStockSuccess': 'Erfolgreich entfernt',
'dashboard.analysis.message.removeStockFailed': 'Das Entfernen ist fehlgeschlagen',
'dashboard.analysis.message.resumingAnalysis': 'Analyseaufgabe wird fortgesetzt...',
'dashboard.analysis.message.deleteSuccess': 'Erfolgreich gelöscht',
'dashboard.analysis.message.deleteFailed': 'Löschen fehlgeschlagen',
'dashboard.analysis.modal.history.delete': 'Löschen',
'dashboard.analysis.modal.history.deleteConfirm': 'Möchten Sie diesen Analysedatensatz wirklich löschen?',
'dashboard.analysis.test': 'Geschäft Nr. {no}, Gongzhuan Road',
'dashboard.analysis.introduce': 'Beschreibung des Indikators',
'dashboard.analysis.total-sales': 'Gesamtumsatz',
+71 -1
View File
@@ -485,6 +485,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': 'Failed to add',
'dashboard.analysis.message.removeStockSuccess': 'Removed successfully',
'dashboard.analysis.message.removeStockFailed': 'Failed to remove',
'dashboard.analysis.message.resumingAnalysis': 'Resuming analysis task...',
'dashboard.analysis.message.deleteSuccess': 'Deleted successfully',
'dashboard.analysis.message.deleteFailed': 'Failed to delete',
'dashboard.analysis.modal.history.delete': 'Delete',
'dashboard.analysis.modal.history.deleteConfirm': 'Are you sure you want to delete this analysis record?',
'dashboard.analysis.test': 'Gongzhuan No.{no} shop',
'dashboard.analysis.introduce': 'Introduce',
'dashboard.analysis.total-sales': 'Total Sales',
@@ -2266,7 +2271,72 @@ const locale = {
'portfolio.alerts.delete': 'Delete',
'portfolio.alerts.deleteConfirm': 'Are you sure you want to delete this alert?',
'portfolio.modal.addAlert': 'Add Alert',
'portfolio.modal.editAlert': 'Edit Alert'
'portfolio.modal.editAlert': 'Edit Alert',
// User Management
'menu.userManage': 'User Management',
'menu.myProfile': 'My Profile',
'common.actions': 'Actions',
'common.refresh': 'Refresh',
'userManage.title': 'User Management',
'userManage.description': 'Manage system users, roles and permissions',
'userManage.createUser': 'Create User',
'userManage.editUser': 'Edit User',
'userManage.username': 'Username',
'userManage.password': 'Password',
'userManage.nickname': 'Nickname',
'userManage.email': 'Email',
'userManage.role': 'Role',
'userManage.status': 'Status',
'userManage.lastLogin': 'Last Login',
'userManage.active': 'Active',
'userManage.disabled': 'Disabled',
'userManage.neverLogin': 'Never',
'userManage.usernameRequired': 'Please enter username',
'userManage.usernamePlaceholder': 'Enter username',
'userManage.passwordRequired': 'Please enter password',
'userManage.passwordPlaceholder': 'Enter password (min 6 chars)',
'userManage.passwordMin': 'Password must be at least 6 characters',
'userManage.nicknamePlaceholder': 'Enter nickname',
'userManage.emailPlaceholder': 'Enter email',
'userManage.emailInvalid': 'Invalid email format',
'userManage.rolePlaceholder': 'Select role',
'userManage.statusPlaceholder': 'Select status',
'userManage.resetPassword': 'Reset Password',
'userManage.resetPasswordWarning': 'This will reset the user\'s password',
'userManage.newPassword': 'New Password',
'userManage.newPasswordPlaceholder': 'Enter new password',
'userManage.confirmDelete': 'Are you sure to delete this user?',
'userManage.roleAdmin': 'Admin',
'userManage.roleManager': 'Manager',
'userManage.roleUser': 'User',
'userManage.roleViewer': 'Viewer',
// Profile
'profile.title': 'My Profile',
'profile.description': 'Manage your account settings and preferences',
'profile.basicInfo': 'Basic Info',
'profile.changePassword': 'Change Password',
'profile.username': 'Username',
'profile.nickname': 'Nickname',
'profile.email': 'Email',
'profile.lastLogin': 'Last Login',
'profile.nicknamePlaceholder': 'Enter your nickname',
'profile.emailPlaceholder': 'Enter your email',
'profile.emailInvalid': 'Invalid email format',
'profile.passwordHint': 'Password must be at least 6 characters',
'profile.oldPassword': 'Current Password',
'profile.newPassword': 'New Password',
'profile.confirmPassword': 'Confirm Password',
'profile.oldPasswordRequired': 'Please enter current password',
'profile.oldPasswordPlaceholder': 'Enter current password',
'profile.newPasswordRequired': 'Please enter new password',
'profile.newPasswordPlaceholder': 'Enter new password',
'profile.confirmPasswordRequired': 'Please confirm password',
'profile.confirmPasswordPlaceholder': 'Confirm new password',
'profile.passwordMin': 'Password must be at least 6 characters',
'profile.passwordMismatch': 'Passwords do not match'
}
export default {
@@ -444,6 +444,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': "L'ajout a échoué",
'dashboard.analysis.message.removeStockSuccess': 'Supprimé avec succès',
'dashboard.analysis.message.removeStockFailed': 'Échec de la suppression',
'dashboard.analysis.message.resumingAnalysis': "Reprise de la tâche d'analyse...",
'dashboard.analysis.message.deleteSuccess': 'Supprimé avec succès',
'dashboard.analysis.message.deleteFailed': 'Échec de la suppression',
'dashboard.analysis.modal.history.delete': 'Supprimer',
'dashboard.analysis.modal.history.deleteConfirm': "Êtes-vous sûr de vouloir supprimer cet enregistrement d'analyse ?",
'dashboard.analysis.test': 'Magasin n° {no}, route Gongzhuan',
'dashboard.analysis.introduce': "Description de l'indicateur",
'dashboard.analysis.total-sales': 'ventes totales',
@@ -444,6 +444,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': '追加に失敗しました',
'dashboard.analysis.message.removeStockSuccess': '正常に削除されました',
'dashboard.analysis.message.removeStockFailed': '削除に失敗しました',
'dashboard.analysis.message.resumingAnalysis': '分析タスクを再開しています...',
'dashboard.analysis.message.deleteSuccess': '正常に削除されました',
'dashboard.analysis.message.deleteFailed': '削除に失敗しました',
'dashboard.analysis.modal.history.delete': '削除',
'dashboard.analysis.modal.history.deleteConfirm': 'この分析記録を削除してもよろしいですか?',
'dashboard.analysis.test': 'ショップ番号 {no}、公庄路',
'dashboard.analysis.introduce': 'インジケーターの説明',
'dashboard.analysis.total-sales': '総売上高',
@@ -443,6 +443,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': '추가 실패',
'dashboard.analysis.message.removeStockSuccess': '성공적으로 제거되었습니다',
'dashboard.analysis.message.removeStockFailed': '제거 실패',
'dashboard.analysis.message.resumingAnalysis': '분석 작업을 재개하는 중...',
'dashboard.analysis.message.deleteSuccess': '성공적으로 삭제되었습니다',
'dashboard.analysis.message.deleteFailed': '삭제 실패',
'dashboard.analysis.modal.history.delete': '삭제',
'dashboard.analysis.modal.history.deleteConfirm': '이 분석 기록을 삭제하시겠습니까?',
'dashboard.analysis.test': '상점 번호 {no}, Gongzhuan Road',
'dashboard.analysis.introduce': '표시기 설명',
'dashboard.analysis.total-sales': '총매출',
@@ -444,6 +444,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': 'เพิ่มล้มเหลว',
'dashboard.analysis.message.removeStockSuccess': 'ลบสำเร็จแล้ว',
'dashboard.analysis.message.removeStockFailed': 'การลบล้มเหลว',
'dashboard.analysis.message.resumingAnalysis': 'กำลังดำเนินการงานวิเคราะห์ต่อ...',
'dashboard.analysis.message.deleteSuccess': 'ลบสำเร็จแล้ว',
'dashboard.analysis.message.deleteFailed': 'การลบล้มเหลว',
'dashboard.analysis.modal.history.delete': 'ลบ',
'dashboard.analysis.modal.history.deleteConfirm': 'คุณแน่ใจหรือไม่ว่าต้องการลบบันทึกการวิเคราะห์นี้?',
'dashboard.analysis.test': 'ร้านค้าเลขที่ {no} ถนน Gongzhan',
'dashboard.analysis.introduce': 'คำอธิบายตัวบ่งชี้',
'dashboard.analysis.total-sales': 'ยอดขายรวม',
+10
View File
@@ -876,6 +876,16 @@ const locale = {
'dashboard.analysis.message.removeStockFailed': 'Xóa thất bại',
'dashboard.analysis.message.resumingAnalysis': 'Đang tiếp tục tác vụ phân tích...',
'dashboard.analysis.message.deleteSuccess': 'Đã xóa thành công',
'dashboard.analysis.message.deleteFailed': 'Xóa thất bại',
'dashboard.analysis.modal.history.delete': 'Xóa',
'dashboard.analysis.modal.history.deleteConfirm': 'Bạn có chắc chắn muốn xóa bản ghi phân tích này không?',
'dashboard.analysis.test': 'Cửa hàng đường Gongzhuan {không}',
'dashboard.analysis.introduce': 'Mô tả chỉ báo',
+71 -1
View File
@@ -485,6 +485,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': '添加失败',
'dashboard.analysis.message.removeStockSuccess': '移除成功',
'dashboard.analysis.message.removeStockFailed': '移除失败',
'dashboard.analysis.message.resumingAnalysis': '正在恢复分析任务...',
'dashboard.analysis.message.deleteSuccess': '删除成功',
'dashboard.analysis.message.deleteFailed': '删除失败',
'dashboard.analysis.modal.history.delete': '删除',
'dashboard.analysis.modal.history.deleteConfirm': '确定要删除这条分析记录吗?',
'dashboard.analysis.test': '工专路 {no} 号店',
'dashboard.analysis.introduce': '指标说明',
'dashboard.analysis.total-sales': '总销售额',
@@ -2075,7 +2080,72 @@ const locale = {
'portfolio.alerts.delete': '删除',
'portfolio.alerts.deleteConfirm': '确定要删除此预警吗?',
'portfolio.modal.addAlert': '添加预警',
'portfolio.modal.editAlert': '编辑预警'
'portfolio.modal.editAlert': '编辑预警',
// User Management
'menu.userManage': '用户管理',
'menu.myProfile': '个人中心',
'common.actions': '操作',
'common.refresh': '刷新',
'userManage.title': '用户管理',
'userManage.description': '管理系统用户、角色和权限',
'userManage.createUser': '创建用户',
'userManage.editUser': '编辑用户',
'userManage.username': '用户名',
'userManage.password': '密码',
'userManage.nickname': '昵称',
'userManage.email': '邮箱',
'userManage.role': '角色',
'userManage.status': '状态',
'userManage.lastLogin': '最后登录',
'userManage.active': '启用',
'userManage.disabled': '禁用',
'userManage.neverLogin': '从未登录',
'userManage.usernameRequired': '请输入用户名',
'userManage.usernamePlaceholder': '输入用户名',
'userManage.passwordRequired': '请输入密码',
'userManage.passwordPlaceholder': '输入密码(至少6位)',
'userManage.passwordMin': '密码至少6个字符',
'userManage.nicknamePlaceholder': '输入昵称',
'userManage.emailPlaceholder': '输入邮箱',
'userManage.emailInvalid': '邮箱格式不正确',
'userManage.rolePlaceholder': '选择角色',
'userManage.statusPlaceholder': '选择状态',
'userManage.resetPassword': '重置密码',
'userManage.resetPasswordWarning': '此操作将重置用户密码',
'userManage.newPassword': '新密码',
'userManage.newPasswordPlaceholder': '输入新密码',
'userManage.confirmDelete': '确定要删除此用户吗?',
'userManage.roleAdmin': '管理员',
'userManage.roleManager': '经理',
'userManage.roleUser': '普通用户',
'userManage.roleViewer': '访客',
// Profile
'profile.title': '个人中心',
'profile.description': '管理您的账户设置和偏好',
'profile.basicInfo': '基本信息',
'profile.changePassword': '修改密码',
'profile.username': '用户名',
'profile.nickname': '昵称',
'profile.email': '邮箱',
'profile.lastLogin': '最后登录',
'profile.nicknamePlaceholder': '输入您的昵称',
'profile.emailPlaceholder': '输入您的邮箱',
'profile.emailInvalid': '邮箱格式不正确',
'profile.passwordHint': '密码至少需要6个字符',
'profile.oldPassword': '当前密码',
'profile.newPassword': '新密码',
'profile.confirmPassword': '确认密码',
'profile.oldPasswordRequired': '请输入当前密码',
'profile.oldPasswordPlaceholder': '输入当前密码',
'profile.newPasswordRequired': '请输入新密码',
'profile.newPasswordPlaceholder': '输入新密码',
'profile.confirmPasswordRequired': '请确认新密码',
'profile.confirmPasswordPlaceholder': '再次输入新密码',
'profile.passwordMin': '密码至少6个字符',
'profile.passwordMismatch': '两次输入的密码不一致'
}
export default {
@@ -445,6 +445,11 @@ const locale = {
'dashboard.analysis.message.addStockFailed': '添加失敗',
'dashboard.analysis.message.removeStockSuccess': '移除成功',
'dashboard.analysis.message.removeStockFailed': '移除失敗',
'dashboard.analysis.message.resumingAnalysis': '正在恢復分析任務...',
'dashboard.analysis.message.deleteSuccess': '刪除成功',
'dashboard.analysis.message.deleteFailed': '刪除失敗',
'dashboard.analysis.modal.history.delete': '刪除',
'dashboard.analysis.modal.history.deleteConfirm': '確定要刪除這條分析記錄嗎?',
'dashboard.analysis.test': '工專路 {no} 號店',
'dashboard.analysis.introduce': '指標說明',
'dashboard.analysis.total-sales': '總銷售額',
@@ -1,9 +1,85 @@
import { asyncRouterMap } from '@/config/router.config'
import storage from 'store'
import { USER_INFO, USER_ROLES } from '@/store/mutation-types'
/**
* Local-only mode: generate routes from frontend static config.
* This removes dependency on legacy PHP `/user/nav`.
* Filter routes based on user permissions.
* Routes with meta.permission containing 'admin' are only visible to admin users.
*
* @param {Array} routes - Route configuration array
* @param {boolean} isAdmin - Whether current user is admin
* @returns {Array} Filtered routes
*/
function filterRoutesByPermission (routes, isAdmin) {
const filtered = []
for (const route of routes) {
// Clone route to avoid mutating original
const clonedRoute = { ...route }
// Check if route requires admin permission
const permissions = clonedRoute.meta?.permission || []
const requiresAdmin = permissions.includes('admin')
// If requires admin but user is not admin, skip this route
if (requiresAdmin && !isAdmin) {
continue
}
// Recursively filter children
if (clonedRoute.children && clonedRoute.children.length > 0) {
clonedRoute.children = filterRoutesByPermission(clonedRoute.children, isAdmin)
}
filtered.push(clonedRoute)
}
return filtered
}
/**
* Check if current user is admin.
* Checks both userInfo.role and stored roles array.
*
* @returns {boolean} True if user is admin
*/
function checkIsAdmin () {
// Check userInfo.role first
const userInfo = storage.get(USER_INFO) || {}
if (userInfo.role) {
const roleId = typeof userInfo.role === 'string' ? userInfo.role : userInfo.role.id
if (roleId === 'admin') {
return true
}
}
// Check stored roles array
const roles = storage.get(USER_ROLES) || []
if (Array.isArray(roles)) {
for (const role of roles) {
if (role && (role.id === 'admin' || role === 'admin')) {
return true
}
}
}
return false
}
/**
* Generate dynamic routes based on user permissions.
* Filters admin-only routes for non-admin users.
*
* @param {string} token - User token (unused, kept for compatibility)
* @returns {Promise<Array>} Promise resolving to filtered routes
*/
export const generatorDynamicRouter = token => {
return Promise.resolve(asyncRouterMap)
return new Promise((resolve) => {
const isAdmin = checkIsAdmin()
// Filter routes based on permissions
const filteredRoutes = filterRoutesByPermission(asyncRouterMap, isAdmin)
resolve(filteredRoutes)
})
}
+6 -6
View File
@@ -4,13 +4,13 @@ import Vuex from 'vuex'
import app from './modules/app'
import user from './modules/user'
// default router permission control
// 默认路由模式为静态路由 (router.config.js)
import permission from './modules/static-router'
// dynamic router permission control
// 动态路由模式(支持基于角色的菜单过滤)
import permission from './modules/async-router'
// dynamic router permission control (Experimental)
// 态路由模式(api请求后端生成
// import permission from './modules/async-router'
// static router permission control (NO filtering)
// 态路由模式(不过滤菜单,已弃用
// import permission from './modules/static-router'
import getters from './getters'
@@ -13,10 +13,15 @@ const permission = {
SET_ROUTERS: (state, routers) => {
state.addRouters = routers
state.routers = constantRouterMap.concat(routers)
},
// Reset routers to force regeneration (used on login/logout)
RESET_ROUTERS: (state) => {
state.addRouters = []
state.routers = constantRouterMap
}
},
actions: {
GenerateRoutes ({ commit }, data) {
GenerateRoutes ({ commit, rootState }, data) {
return new Promise((resolve, reject) => {
const { token } = data
generatorDynamicRouter(token).then(routers => {
@@ -26,6 +31,10 @@ const permission = {
reject(e)
})
})
},
// Reset routes action
ResetRoutes ({ commit }) {
commit('RESET_ROUTERS')
}
}
}
+27 -6
View File
@@ -30,7 +30,11 @@ function getStoredToken () {
}
const initialInfo = getStoredInfo()
const initialRoles = getStoredRoles()
// If is_demo is missing (legacy cache), force roles to empty to trigger GetInfo in permission.js
let initialRoles = getStoredRoles()
if (initialInfo && typeof initialInfo.is_demo === 'undefined') {
initialRoles = []
}
const initialToken = getStoredToken() || ''
const initialName = initialInfo.nickname || initialInfo.username || ''
const initialAvatar = initialInfo.avatar || ''
@@ -66,7 +70,7 @@ const user = {
actions: {
// 登录
Login ({ commit }, userInfo) {
Login ({ commit, dispatch }, userInfo) {
return new Promise((resolve, reject) => {
login(userInfo).then(response => {
// 适配 Python 后端响应格式
@@ -86,11 +90,23 @@ const user = {
commit('SET_NAME', { name: name, welcome: welcome() })
commit('SET_AVATAR', info.avatar || '/avatar2.jpg')
// 设置默认角色,防止路由鉴权失败
const roles = [{ id: 'admin', permissionList: ['dashboard', 'exception', 'account'] }]
// 从服务器返回的角色信息设置角色
let roles = [DEFAULT_ROLE]
if (info.role) {
// role: { id: 'admin', permissions: [...] }
const roleId = info.role.id || info.role
const permissions = info.role.permissions || []
roles = [{
id: roleId,
permissionList: permissions.length > 0 ? permissions : ['dashboard']
}]
}
commit('SET_ROLES', roles)
storage.set(USER_ROLES, roles, expiresAt)
// 重置路由,强制重新生成(根据新用户的角色)
dispatch('ResetRoutes')
resolve(response)
} else {
reject(new Error((response && response.msg) || 'Login failed'))
@@ -279,7 +295,8 @@ const user = {
GetInfo ({ commit, state }) {
return new Promise((resolve, reject) => {
// 用户信息已经在登录时保存到 store 中,直接返回
if (state.info && Object.keys(state.info).length > 0) {
// 增加 check: 必须包含 is_demo 字段,否则视为过期缓存,强制刷新
if (state.info && Object.keys(state.info).length > 0 && typeof state.info.is_demo !== 'undefined') {
// 补全 Roles
const info = state.info
if (info.role) {
@@ -379,7 +396,7 @@ const user = {
},
// 登出
Logout ({ commit, state }) {
Logout ({ commit, state, dispatch }) {
return new Promise((resolve) => {
// 兼容旧登出与新后端登出
const req = typeof apiLogout === 'function' ? apiLogout() : logout(state.token)
@@ -392,12 +409,16 @@ const user = {
storage.remove(ACCESS_TOKEN)
storage.remove(USER_INFO)
storage.remove(USER_ROLES)
// 重置路由
dispatch('ResetRoutes')
resolve()
}).catch(() => {
// 登出失败时也继续执行,确保清理本地状态
storage.remove(ACCESS_TOKEN)
storage.remove(USER_INFO)
storage.remove(USER_ROLES)
// 重置路由
dispatch('ResetRoutes')
resolve()
}).finally(() => {
})
+6 -3
View File
@@ -15,18 +15,21 @@ const request = axios.create({
// API 请求的默认前缀
// 生产环境应由 Nginx 处理,开发环境由 devServer proxy 处理
baseURL: '/',
timeout: 6000, // 请求超时时间
timeout: 30000, // Default request timeout 30s
withCredentials: true // 允许携带 cookies
})
// Extended timeout for long-running AI analysis APIs
export const ANALYSIS_TIMEOUT = 180000 // 3 minutes for AI analysis
// 异常拦截处理器
const errorHandler = (error) => {
if (error.response) {
const data = error.response.data
if (error.response.status === 403) {
notification.error({
message: 'Forbidden',
description: data.message
message: '(Demo Mode)',
description: data.msg || data.message || 'Read-only in demo mode'
})
}
if (error.response.status === 401 && !(data.result && data.result.isLogin)) {
@@ -891,6 +891,17 @@ export default {
if (!this.hasAnalysisResults) {
this.reset()
}
// If analysis stopped with results, mark all agents as completed
if (this.hasAnalysisResults) {
for (let i = 0; i < this.agents.length; i++) {
if (this.agentStatusMap[i] !== 'completed') {
this.agentStatusMap[i] = 'completed'
}
}
this.hasResult = true
this.currentStep = this.agents.length + 1
this.selectFirstAvailableAgent()
}
}
},
immediate: true
@@ -975,20 +986,23 @@ export default {
this.addLog('SYSTEM', `Initiating Quantum Analysis for ${this.symbol || 'DEMO'}...`, 'var(--neon-main)')
// agentFundamental Analyst
// reversedAgents Fundamental Analyst
this.$nextTick(() => {
const listContainer = this.$refs.agentsList
if (listContainer) {
// DOM
setTimeout(() => {
listContainer.scrollTop = listContainer.scrollHeight
}, 100)
}
})
// 使agent 5
// agent
this.startSimulation()
// Start simulation animation while backend processes
this.addLog('SYSTEM', 'Starting multi-agent analysis...', '#52c41a')
// Small delay before starting simulation for visual effect
setTimeout(() => {
if (this.analyzing) {
this.startSimulation()
}
}, 500)
},
//
@@ -1447,12 +1461,12 @@ export default {
return 'completed'
}
// 使 agentStatusMap
// Use local agentStatusMap for simulation status
if (this.agentStatusMap[agentIndex]) {
return this.agentStatusMap[agentIndex]
}
// waiting
// Default to waiting
return 'waiting'
},
+90 -22
View File
@@ -331,9 +331,21 @@
>
{{ $t('dashboard.analysis.modal.history.viewResult') }}
</a-button>
<!-- <span style="color: #999; font-size: 12px; margin-left: 12px;">
{{ formatTime(item.createtime) }}
</span> -->
<a-popconfirm
:title="$t('dashboard.analysis.modal.history.deleteConfirm')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@confirm="deleteHistoryItem(item)"
>
<a-button
type="link"
size="small"
icon="delete"
style="color: #ff4d4f;"
>
{{ $t('dashboard.analysis.modal.history.delete') }}
</a-button>
</a-popconfirm>
</div>
</div>
</template>
@@ -357,7 +369,7 @@
<script>
import { mapGetters, mapState } from 'vuex'
import { getUserInfo } from '@/api/login'
import { getWatchlist, addWatchlist, removeWatchlist, getWatchlistPrices, multiAnalysis, getAnalysisTaskStatus, getAnalysisHistoryList, getConfig, getMarketTypes, searchSymbols, getHotSymbols } from '@/api/market'
import { getWatchlist, addWatchlist, removeWatchlist, getWatchlistPrices, multiAnalysis, getAnalysisTaskStatus, getAnalysisHistoryList, deleteAnalysisTask, getConfig, getMarketTypes, searchSymbols, getHotSymbols } from '@/api/market'
import MetaverseAnalysis from './components/index'
import { DEFAULT_AI_MODEL_MAP, mergeModelMaps, modelMapToOptions } from '@/config/aiModels'
@@ -456,6 +468,8 @@ export default {
// Local-only mode: load watchlist immediately (userId defaults to 1).
// This also prevents a blank watchlist if user info fetch is slow/fails.
this.loadWatchlist()
// Check for any pending/running analysis tasks from previous session
this.checkPendingTasks()
},
mounted () {
//
@@ -549,29 +563,35 @@ export default {
return
}
// Reset state
this.analyzing = true
this.currentTaskId = null
const [market, symbol] = this.selectedSymbol.split(':')
//
const language = this.$store.getters.lang || 'zh-CN'
const useMultiAgent = this.$store.getters.useMultiAgent !== false
try {
// API
// use_multi_agent使
const useMultiAgent = this.$store.getters.useMultiAgent !== false //
// Call API to create analysis task
const res = await multiAnalysis({
userid: this.userId,
market: market,
symbol: symbol,
language: language,
use_multi_agent: useMultiAgent,
model: this.selectedModel //
model: this.selectedModel,
timeframe: '1D'
})
if (res && res.code === 1 && res.data) {
//
if (res.data.overview || res.data.fundamental || res.data.technical) {
//
if (res.data.task_id) {
// Task created, start polling for results
// Frontend will show simulation animation while backend processes
this.currentTaskId = Number(res.data.task_id) || null
this.$message.info(this.$t('dashboard.analysis.message.taskCreated') || 'Analysis task created, processing...')
this.startTaskStatusPolling()
} else if (res.data.overview || res.data.fundamental || res.data.technical) {
// Direct result (legacy support)
this.analysisResults = {
overview: res.data.overview || null,
fundamental: res.data.fundamental || null,
@@ -584,15 +604,8 @@ export default {
risk_debate: res.data.risk_debate || null,
final_decision: res.data.final_decision || null
}
this.$message.success(this.$t('dashboard.analysis.message.analysisCompleteCache'))
this.$message.success(this.$t('dashboard.analysis.message.analysisComplete'))
this.analyzing = false
} else if (res.data.task_id) {
//
this.currentTaskId = Number(res.data.task_id) || null
this.$message.info(this.$t('dashboard.analysis.message.taskCreated'))
//
this.startTaskStatusPolling()
} else {
throw new Error('返回数据格式错误')
}
@@ -600,7 +613,7 @@ export default {
throw new Error(res?.msg || '创建分析任务失败')
}
} catch (error) {
// QDT
console.error('Analysis failed:', error)
const errorMsg = error?.response?.data?.msg || error?.message || this.$t('dashboard.analysis.message.analysisFailed')
this.$message.error(errorMsg)
this.analyzing = false
@@ -680,6 +693,47 @@ export default {
this.historyLoading = false
}
},
// Check for pending/processing tasks when user returns to page
async checkPendingTasks () {
try {
const res = await getAnalysisHistoryList({
userid: this.userId,
page: 1,
pagesize: 5
})
if (res && res.code === 1 && res.data && res.data.list) {
// Find the most recent pending task
const pendingTask = res.data.list.find(t => t.status === 'pending')
if (pendingTask) {
// There's a pending task - show notification and start polling
this.currentTaskId = pendingTask.id
this.selectedSymbol = `${pendingTask.market}:${pendingTask.symbol}`
this.analyzing = true
this.$message.info(this.$t('dashboard.analysis.message.resumingAnalysis') || 'Resuming analysis in progress...')
this.startTaskStatusPolling()
} else {
// Check if most recent task just completed (within last 30 seconds)
const recentCompleted = res.data.list.find(t => {
if (t.status !== 'completed') return false
const completedAt = t.completetime
if (!completedAt) return false
const now = Math.floor(Date.now() / 1000)
return (now - completedAt) < 30 // Within 30 seconds
})
if (recentCompleted) {
// Show result of recently completed task
this.viewHistoryResult(recentCompleted)
}
}
}
} catch (error) {
// Silent fail - not critical
console.warn('Failed to check pending tasks:', error)
}
},
//
async viewHistoryResult (task) {
if (task.status !== 'completed') {
@@ -718,6 +772,20 @@ export default {
this.$message.error(this.$t('dashboard.analysis.message.analysisFailed'))
}
},
// Delete history item
async deleteHistoryItem (item) {
try {
const res = await deleteAnalysisTask({ task_id: item.id })
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.deleteSuccess'))
this.loadHistoryList()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.deleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('dashboard.analysis.message.deleteFailed'))
}
},
//
formatTime (timestamp) {
if (!timestamp) return '-'
@@ -159,8 +159,8 @@ export default {
const market = this.market || ''
const res = await request({
url: '/api/indicator/backtest/history',
method: 'post',
data: {
method: 'get',
params: {
userid: this.userId,
limit: 100,
offset: 0,
@@ -185,8 +185,8 @@ export default {
try {
const res = await request({
url: '/api/indicator/backtest/get',
method: 'post',
data: { userid: this.userId, runId: record.id }
method: 'get',
params: { userid: this.userId, runId: record.id }
})
if (res && res.code === 1 && res.data) {
this.$emit('view', res.data)
@@ -1492,8 +1492,8 @@ registerOverlay({
try {
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
method: 'get',
params: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
@@ -1614,13 +1614,13 @@ registerOverlay({
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
method: 'get',
params: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 500,
beforeTime: beforeTime //
before_time: beforeTime //
}
})
@@ -1742,13 +1742,13 @@ registerOverlay({
const earliestTime = Math.floor(earliestTimestamp / 1000) //
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
method: 'get',
params: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 500,
beforeTime: earliestTime //
before_time: earliestTime //
}
})
@@ -1805,8 +1805,8 @@ registerOverlay({
// 5K线
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
method: 'get',
params: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
@@ -1083,8 +1083,8 @@ export default {
try {
const res = await request({
url: '/api/indicator/getIndicators',
method: 'post',
data: {
method: 'get',
params: {
userid: userId.value
}
})
+472
View File
@@ -0,0 +1,472 @@
<template>
<div class="profile-page" :class="{ 'theme-dark': isDarkTheme }">
<div class="page-header">
<h2 class="page-title">
<a-icon type="user" />
<span>{{ $t('profile.title') || 'My Profile' }}</span>
</h2>
<p class="page-desc">{{ $t('profile.description') || 'Manage your account settings and preferences' }}</p>
</div>
<a-row :gutter="24">
<!-- Profile Card -->
<a-col :xs="24" :md="8">
<a-card :bordered="false" class="profile-card">
<div class="avatar-section">
<a-avatar :size="100" :src="profile.avatar || '/avatar2.jpg'" />
<h3 class="username">{{ profile.nickname || profile.username }}</h3>
<p class="user-role">
<a-tag :color="getRoleColor(profile.role)">
{{ getRoleLabel(profile.role) }}
</a-tag>
</p>
</div>
<a-divider />
<div class="profile-info">
<div class="info-item">
<a-icon type="user" />
<span class="label">{{ $t('profile.username') || 'Username' }}:</span>
<span class="value">{{ profile.username }}</span>
</div>
<div class="info-item">
<a-icon type="mail" />
<span class="label">{{ $t('profile.email') || 'Email' }}:</span>
<span class="value">{{ profile.email || '-' }}</span>
</div>
<div class="info-item">
<a-icon type="calendar" />
<span class="label">{{ $t('profile.lastLogin') || 'Last Login' }}:</span>
<span class="value">{{ formatTime(profile.last_login_at) || '-' }}</span>
</div>
</div>
</a-card>
</a-col>
<!-- Edit Profile -->
<a-col :xs="24" :md="16">
<a-card :bordered="false" class="edit-card">
<a-tabs v-model="activeTab">
<!-- Basic Info Tab -->
<a-tab-pane key="basic" :tab="$t('profile.basicInfo') || 'Basic Info'">
<a-form :form="profileForm" layout="vertical" class="profile-form">
<a-form-item :label="$t('profile.nickname') || 'Nickname'">
<a-input
v-decorator="['nickname', { initialValue: profile.nickname }]"
:placeholder="$t('profile.nicknamePlaceholder') || 'Enter your nickname'"
>
<a-icon slot="prefix" type="smile" />
</a-input>
</a-form-item>
<a-form-item :label="$t('profile.email') || 'Email'">
<a-input
v-decorator="['email', {
initialValue: profile.email,
rules: [{ type: 'email', message: $t('profile.emailInvalid') || 'Invalid email format' }]
}]"
:placeholder="$t('profile.emailPlaceholder') || 'Enter your email'"
>
<a-icon slot="prefix" type="mail" />
</a-input>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="saving" @click="handleSaveProfile">
<a-icon type="save" />
{{ $t('common.save') || 'Save' }}
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<!-- Change Password Tab -->
<a-tab-pane key="password" :tab="$t('profile.changePassword') || 'Change Password'">
<a-form :form="passwordForm" layout="vertical" class="password-form">
<a-alert
:message="$t('profile.passwordHint') || 'Password must be at least 6 characters'"
type="info"
showIcon
style="margin-bottom: 24px"
/>
<a-form-item :label="$t('profile.oldPassword') || 'Current Password'">
<a-input-password
v-decorator="['old_password', {
rules: [{ required: true, message: $t('profile.oldPasswordRequired') || 'Please enter current password' }]
}]"
:placeholder="$t('profile.oldPasswordPlaceholder') || 'Enter current password'"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-item>
<a-form-item :label="$t('profile.newPassword') || 'New Password'">
<a-input-password
v-decorator="['new_password', {
rules: [
{ required: true, message: $t('profile.newPasswordRequired') || 'Please enter new password' },
{ min: 6, message: $t('profile.passwordMin') || 'Password must be at least 6 characters' }
]
}]"
:placeholder="$t('profile.newPasswordPlaceholder') || 'Enter new password'"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-item>
<a-form-item :label="$t('profile.confirmPassword') || 'Confirm Password'">
<a-input-password
v-decorator="['confirm_password', {
rules: [
{ required: true, message: $t('profile.confirmPasswordRequired') || 'Please confirm password' },
{ validator: validateConfirmPassword }
]
}]"
:placeholder="$t('profile.confirmPasswordPlaceholder') || 'Confirm new password'"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="changingPassword" @click="handleChangePassword">
<a-icon type="key" />
{{ $t('profile.changePassword') || 'Change Password' }}
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script>
import { getProfile, updateProfile, changePassword } from '@/api/user'
import { baseMixin } from '@/store/app-mixin'
export default {
name: 'Profile',
mixins: [baseMixin],
data () {
return {
loading: false,
saving: false,
changingPassword: false,
activeTab: 'basic',
profile: {
id: null,
username: '',
nickname: '',
email: '',
avatar: '',
role: 'user',
last_login_at: null
}
}
},
computed: {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
}
},
beforeCreate () {
this.profileForm = this.$form.createForm(this, { name: 'profile' })
this.passwordForm = this.$form.createForm(this, { name: 'password' })
},
mounted () {
this.loadProfile()
},
methods: {
async loadProfile () {
this.loading = true
try {
const res = await getProfile()
if (res.code === 1) {
this.profile = res.data
this.$nextTick(() => {
this.profileForm.setFieldsValue({
nickname: this.profile.nickname,
email: this.profile.email
})
})
} else {
this.$message.error(res.msg || 'Failed to load profile')
}
} catch (error) {
this.$message.error('Failed to load profile')
} finally {
this.loading = false
}
},
handleSaveProfile () {
this.profileForm.validateFields(async (err, values) => {
if (err) return
this.saving = true
try {
const res = await updateProfile(values)
if (res.code === 1) {
this.$message.success(res.msg || 'Profile updated successfully')
this.loadProfile()
} else {
this.$message.error(res.msg || 'Update failed')
}
} catch (error) {
this.$message.error('Update failed')
} finally {
this.saving = false
}
})
},
validateConfirmPassword (rule, value, callback) {
const newPassword = this.passwordForm.getFieldValue('new_password')
if (value && value !== newPassword) {
callback(this.$t('profile.passwordMismatch') || 'Passwords do not match')
} else {
callback()
}
},
handleChangePassword () {
this.passwordForm.validateFields(async (err, values) => {
if (err) return
this.changingPassword = true
try {
const res = await changePassword({
old_password: values.old_password,
new_password: values.new_password
})
if (res.code === 1) {
this.$message.success(res.msg || 'Password changed successfully')
this.passwordForm.resetFields()
} else {
this.$message.error(res.msg || 'Change password failed')
}
} catch (error) {
this.$message.error('Change password failed')
} finally {
this.changingPassword = false
}
})
},
getRoleColor (role) {
const colors = {
admin: 'red',
manager: 'orange',
user: 'blue',
viewer: 'default'
}
return colors[role] || 'default'
},
getRoleLabel (role) {
const labels = {
admin: this.$t('userManage.roleAdmin') || 'Admin',
manager: this.$t('userManage.roleManager') || 'Manager',
user: this.$t('userManage.roleUser') || 'User',
viewer: this.$t('userManage.roleViewer') || 'Viewer'
}
return labels[role] || role
},
formatTime (timestamp) {
if (!timestamp) return ''
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
return date.toLocaleString()
}
}
}
</script>
<style lang="less" scoped>
@primary-color: #1890ff;
.profile-page {
padding: 24px;
min-height: calc(100vh - 120px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px 0;
color: #1e3a5f;
display: flex;
align-items: center;
gap: 12px;
.anticon {
font-size: 28px;
color: @primary-color;
}
}
.page-desc {
color: #64748b;
font-size: 14px;
margin: 0;
}
}
.profile-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
text-align: center;
.avatar-section {
padding: 20px 0;
.ant-avatar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.username {
margin: 16px 0 8px;
font-size: 20px;
font-weight: 600;
color: #1e3a5f;
}
.user-role {
margin: 0;
}
}
.profile-info {
text-align: left;
.info-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.anticon {
font-size: 16px;
color: @primary-color;
margin-right: 12px;
}
.label {
color: #64748b;
margin-right: 8px;
}
.value {
color: #1e3a5f;
font-weight: 500;
}
}
}
}
.edit-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
.profile-form,
.password-form {
max-width: 500px;
/deep/ .ant-input,
/deep/ .ant-input-password {
border-radius: 8px;
}
}
}
// Dark theme
&.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
.page-header {
.page-title {
color: #e0e6ed;
}
.page-desc {
color: #8b949e;
}
}
.profile-card,
.edit-card {
background: #1e222d;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
/deep/ .ant-card-body {
background: #1e222d;
}
}
.profile-card {
.avatar-section {
.username {
color: #e0e6ed;
}
}
.profile-info {
.info-item {
border-bottom-color: #30363d;
.label {
color: #8b949e;
}
.value {
color: #e0e6ed;
}
}
}
}
.edit-card {
/deep/ .ant-tabs-bar {
border-bottom-color: #30363d;
}
/deep/ .ant-tabs-tab {
color: #8b949e;
&:hover {
color: #e0e6ed;
}
}
/deep/ .ant-tabs-tab-active {
color: @primary-color;
}
/deep/ .ant-form-item-label label {
color: #c9d1d9;
}
/deep/ .ant-input,
/deep/ .ant-input-password {
background: #0d1117;
border-color: #30363d;
color: #c9d1d9;
&:hover,
&:focus {
border-color: @primary-color;
}
}
}
}
}
</style>
@@ -2409,8 +2409,8 @@ export default {
// 使 indicator-analysis
const res = await request({
url: '/api/indicator/getIndicators',
method: 'post',
data: {
method: 'get',
params: {
userid: userId
}
})
@@ -0,0 +1,556 @@
<template>
<div class="user-manage-page" :class="{ 'theme-dark': isDarkTheme }">
<div class="page-header">
<h2 class="page-title">
<a-icon type="team" />
<span>{{ $t('userManage.title') || 'User Management' }}</span>
</h2>
<p class="page-desc">{{ $t('userManage.description') || 'Manage system users, roles and permissions' }}</p>
</div>
<!-- Toolbar -->
<div class="toolbar">
<a-button type="primary" @click="showCreateModal">
<a-icon type="user-add" />
{{ $t('userManage.createUser') || 'Create User' }}
</a-button>
<a-button @click="loadUsers">
<a-icon type="reload" />
{{ $t('common.refresh') || 'Refresh' }}
</a-button>
</div>
<!-- User Table -->
<a-card :bordered="false" class="user-table-card">
<a-table
:columns="columns"
:dataSource="users"
:loading="loading"
:pagination="pagination"
:rowKey="record => record.id"
@change="handleTableChange"
>
<!-- Status Column -->
<template slot="status" slot-scope="text">
<a-tag :color="text === 'active' ? 'green' : 'red'">
{{ text === 'active' ? ($t('userManage.active') || 'Active') : ($t('userManage.disabled') || 'Disabled') }}
</a-tag>
</template>
<!-- Role Column -->
<template slot="role" slot-scope="text">
<a-tag :color="getRoleColor(text)">
{{ getRoleLabel(text) }}
</a-tag>
</template>
<!-- Last Login Column -->
<template slot="last_login_at" slot-scope="text">
<span v-if="text">{{ formatTime(text) }}</span>
<span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span>
</template>
<!-- Actions Column -->
<template slot="action" slot-scope="text, record">
<a-space>
<a-tooltip :title="$t('common.edit') || 'Edit'">
<a-button type="link" size="small" @click="showEditModal(record)">
<a-icon type="edit" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.resetPassword') || 'Reset Password'">
<a-button type="link" size="small" @click="showResetPasswordModal(record)">
<a-icon type="key" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('common.delete') || 'Delete'">
<a-popconfirm
:title="$t('userManage.confirmDelete') || 'Are you sure to delete this user?'"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" :disabled="record.id === currentUserId">
<a-icon type="delete" style="color: #ff4d4f" />
</a-button>
</a-popconfirm>
</a-tooltip>
</a-space>
</template>
</a-table>
</a-card>
<!-- Create/Edit User Modal -->
<a-modal
v-model="modalVisible"
:title="isEdit ? ($t('userManage.editUser') || 'Edit User') : ($t('userManage.createUser') || 'Create User')"
:confirmLoading="modalLoading"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form :form="form" layout="vertical">
<a-form-item :label="$t('userManage.username') || 'Username'">
<a-input
v-decorator="['username', {
rules: [{ required: !isEdit, message: $t('userManage.usernameRequired') || 'Please enter username' }]
}]"
:disabled="isEdit"
:placeholder="$t('userManage.usernamePlaceholder') || 'Enter username'"
>
<a-icon slot="prefix" type="user" />
</a-input>
</a-form-item>
<a-form-item v-if="!isEdit" :label="$t('userManage.password') || 'Password'">
<a-input-password
v-decorator="['password', {
rules: [
{ required: true, message: $t('userManage.passwordRequired') || 'Please enter password' },
{ min: 6, message: $t('userManage.passwordMin') || 'Password must be at least 6 characters' }
]
}]"
:placeholder="$t('userManage.passwordPlaceholder') || 'Enter password (min 6 characters)'"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-item>
<a-form-item :label="$t('userManage.nickname') || 'Nickname'">
<a-input
v-decorator="['nickname']"
:placeholder="$t('userManage.nicknamePlaceholder') || 'Enter nickname'"
>
<a-icon slot="prefix" type="smile" />
</a-input>
</a-form-item>
<a-form-item :label="$t('userManage.email') || 'Email'">
<a-input
v-decorator="['email', {
rules: [{ type: 'email', message: $t('userManage.emailInvalid') || 'Invalid email format' }]
}]"
:placeholder="$t('userManage.emailPlaceholder') || 'Enter email'"
>
<a-icon slot="prefix" type="mail" />
</a-input>
</a-form-item>
<a-form-item :label="$t('userManage.role') || 'Role'">
<a-select
v-decorator="['role', { initialValue: 'user' }]"
:placeholder="$t('userManage.rolePlaceholder') || 'Select role'"
>
<a-select-option v-for="role in roles" :key="role.id" :value="role.id">
{{ getRoleLabel(role.id) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isEdit" :label="$t('userManage.status') || 'Status'">
<a-select
v-decorator="['status', { initialValue: 'active' }]"
:placeholder="$t('userManage.statusPlaceholder') || 'Select status'"
>
<a-select-option value="active">{{ $t('userManage.active') || 'Active' }}</a-select-option>
<a-select-option value="disabled">{{ $t('userManage.disabled') || 'Disabled' }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- Reset Password Modal -->
<a-modal
v-model="resetPasswordVisible"
:title="$t('userManage.resetPassword') || 'Reset Password'"
:confirmLoading="resetPasswordLoading"
@ok="handleResetPassword"
>
<a-form :form="resetPasswordForm" layout="vertical">
<a-alert
:message="$t('userManage.resetPasswordWarning') || 'This will reset the user\'s password'"
type="warning"
showIcon
style="margin-bottom: 16px"
/>
<a-form-item :label="$t('userManage.newPassword') || 'New Password'">
<a-input-password
v-decorator="['new_password', {
rules: [
{ required: true, message: $t('userManage.passwordRequired') || 'Please enter new password' },
{ min: 6, message: $t('userManage.passwordMin') || 'Password must be at least 6 characters' }
]
}]"
:placeholder="$t('userManage.newPasswordPlaceholder') || 'Enter new password'"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles } from '@/api/user'
import { baseMixin } from '@/store/app-mixin'
import { mapGetters } from 'vuex'
export default {
name: 'UserManage',
mixins: [baseMixin],
data () {
return {
loading: false,
users: [],
roles: [],
pagination: {
current: 1,
pageSize: 10,
total: 0
},
// Create/Edit Modal
modalVisible: false,
modalLoading: false,
isEdit: false,
editingUser: null,
// Reset Password Modal
resetPasswordVisible: false,
resetPasswordLoading: false,
resetPasswordUserId: null
}
},
computed: {
...mapGetters(['userInfo']),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
currentUserId () {
return this.userInfo?.id
},
columns () {
return [
{
title: 'ID',
dataIndex: 'id',
width: 60
},
{
title: this.$t('userManage.username') || 'Username',
dataIndex: 'username',
width: 120
},
{
title: this.$t('userManage.nickname') || 'Nickname',
dataIndex: 'nickname',
width: 120
},
{
title: this.$t('userManage.email') || 'Email',
dataIndex: 'email',
width: 180
},
{
title: this.$t('userManage.role') || 'Role',
dataIndex: 'role',
width: 100,
scopedSlots: { customRender: 'role' }
},
{
title: this.$t('userManage.status') || 'Status',
dataIndex: 'status',
width: 100,
scopedSlots: { customRender: 'status' }
},
{
title: this.$t('userManage.lastLogin') || 'Last Login',
dataIndex: 'last_login_at',
width: 160,
scopedSlots: { customRender: 'last_login_at' }
},
{
title: this.$t('common.actions') || 'Actions',
dataIndex: 'action',
width: 150,
scopedSlots: { customRender: 'action' }
}
]
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
this.resetPasswordForm = this.$form.createForm(this, { name: 'resetPassword' })
},
mounted () {
this.loadUsers()
this.loadRoles()
},
methods: {
async loadUsers () {
this.loading = true
try {
const res = await getUserList({
page: this.pagination.current,
page_size: this.pagination.pageSize
})
if (res.code === 1) {
this.users = res.data.items || []
this.pagination.total = res.data.total || 0
} else {
this.$message.error(res.msg || 'Failed to load users')
}
} catch (error) {
this.$message.error('Failed to load users')
} finally {
this.loading = false
}
},
async loadRoles () {
try {
const res = await getRoles()
if (res.code === 1) {
this.roles = res.data.roles || []
}
} catch (error) {
console.error('Failed to load roles:', error)
}
},
handleTableChange (pagination) {
this.pagination.current = pagination.current
this.pagination.pageSize = pagination.pageSize
this.loadUsers()
},
showCreateModal () {
this.isEdit = false
this.editingUser = null
this.modalVisible = true
this.$nextTick(() => {
this.form.resetFields()
})
},
showEditModal (record) {
this.isEdit = true
this.editingUser = record
this.modalVisible = true
this.$nextTick(() => {
this.form.setFieldsValue({
username: record.username,
nickname: record.nickname,
email: record.email,
role: record.role,
status: record.status
})
})
},
handleModalCancel () {
this.modalVisible = false
this.form.resetFields()
},
handleModalOk () {
this.form.validateFields(async (err, values) => {
if (err) return
this.modalLoading = true
try {
let res
if (this.isEdit) {
res = await updateUser(this.editingUser.id, {
nickname: values.nickname,
email: values.email,
role: values.role,
status: values.status
})
} else {
res = await createUser(values)
}
if (res.code === 1) {
this.$message.success(res.msg || 'Success')
this.modalVisible = false
this.form.resetFields()
this.loadUsers()
} else {
this.$message.error(res.msg || 'Operation failed')
}
} catch (error) {
this.$message.error('Operation failed')
} finally {
this.modalLoading = false
}
})
},
async handleDelete (id) {
try {
const res = await deleteUser(id)
if (res.code === 1) {
this.$message.success(res.msg || 'User deleted')
this.loadUsers()
} else {
this.$message.error(res.msg || 'Delete failed')
}
} catch (error) {
this.$message.error('Delete failed')
}
},
showResetPasswordModal (record) {
this.resetPasswordUserId = record.id
this.resetPasswordVisible = true
this.$nextTick(() => {
this.resetPasswordForm.resetFields()
})
},
handleResetPassword () {
this.resetPasswordForm.validateFields(async (err, values) => {
if (err) return
this.resetPasswordLoading = true
try {
const res = await resetUserPassword({
user_id: this.resetPasswordUserId,
new_password: values.new_password
})
if (res.code === 1) {
this.$message.success(res.msg || 'Password reset successfully')
this.resetPasswordVisible = false
this.resetPasswordForm.resetFields()
} else {
this.$message.error(res.msg || 'Reset failed')
}
} catch (error) {
this.$message.error('Reset failed')
} finally {
this.resetPasswordLoading = false
}
})
},
getRoleColor (role) {
const colors = {
admin: 'red',
manager: 'orange',
user: 'blue',
viewer: 'default'
}
return colors[role] || 'default'
},
getRoleLabel (role) {
const labels = {
admin: this.$t('userManage.roleAdmin') || 'Admin',
manager: this.$t('userManage.roleManager') || 'Manager',
user: this.$t('userManage.roleUser') || 'User',
viewer: this.$t('userManage.roleViewer') || 'Viewer'
}
return labels[role] || role
},
formatTime (timestamp) {
if (!timestamp) return ''
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
return date.toLocaleString()
}
}
}
</script>
<style lang="less" scoped>
@primary-color: #1890ff;
.user-manage-page {
padding: 24px;
min-height: calc(100vh - 120px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px 0;
color: #1e3a5f;
display: flex;
align-items: center;
gap: 12px;
.anticon {
font-size: 28px;
color: @primary-color;
}
}
.page-desc {
color: #64748b;
font-size: 14px;
margin: 0;
}
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.user-table-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
.text-muted {
color: #94a3b8;
}
}
// Dark theme
&.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
.page-header {
.page-title {
color: #e0e6ed;
}
.page-desc {
color: #8b949e;
}
}
.user-table-card {
background: #1e222d;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
/deep/ .ant-card-body {
background: #1e222d;
}
/deep/ .ant-table {
background: #1e222d;
color: #c9d1d9;
.ant-table-thead > tr > th {
background: #252a36;
color: #e0e6ed;
border-bottom-color: #30363d;
}
.ant-table-tbody > tr > td {
border-bottom-color: #30363d;
}
.ant-table-tbody > tr:hover > td {
background: #252a36;
}
}
.text-muted {
color: #6e7681;
}
}
}
}
</style>