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
@@ -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>