feat: improve authentication and user management
- Fix token missing issue after email code registration/login - Add last login time update for code-based login - Support email login in addition to username login - Fix password login for code-registered users (allow setting password) - Fix referral code parameter passing from URL hash - Add email column to user management table - Improve profile page layout (align card heights, reorganize layout) - Add i18n support for Register Bonus, Referral Bonus, Code Lock Minutes, Code Max Attempts - Fix billing service add_credits to support reference_id parameter - Update password handling documentation
This commit is contained in:
@@ -1585,13 +1585,21 @@ export default {
|
||||
async runMonitorNow (id) {
|
||||
this.runningMonitor = id
|
||||
try {
|
||||
// 传递当前语言给后端
|
||||
// 传递当前语言给后端,使用异步模式
|
||||
const currentLang = this.$store.getters.lang || 'en-US'
|
||||
const res = await runMonitor(id, { language: currentLang })
|
||||
const res = await runMonitor(id, { language: currentLang, async: true })
|
||||
if (res && res.code === 1) {
|
||||
if (res.data?.success) {
|
||||
// 异步模式:后端立即返回,在后台执行
|
||||
if (res.data?.status === 'running') {
|
||||
this.$message.success(this.$t('portfolio.message.monitorRunning'))
|
||||
this.$notification.info({
|
||||
message: this.$t('portfolio.monitors.runningTitle'),
|
||||
description: this.$t('portfolio.monitors.runningDesc'),
|
||||
duration: 5
|
||||
})
|
||||
} else if (res.data?.success) {
|
||||
// 同步模式返回结果(兼容旧逻辑)
|
||||
this.$message.success(this.$t('portfolio.message.monitorRunSuccess'))
|
||||
// Show analysis result in a modal or notification
|
||||
if (res.data.analysis) {
|
||||
this.$notification.open({
|
||||
message: this.$t('portfolio.monitors.analysisResult'),
|
||||
@@ -1599,13 +1607,22 @@ export default {
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.$message.error(res.data?.error || this.$t('portfolio.message.monitorRunFailed'))
|
||||
} else if (res.data?.error) {
|
||||
this.$message.error(res.data.error || this.$t('portfolio.message.monitorRunFailed'))
|
||||
}
|
||||
this.loadMonitors()
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(this.$t('portfolio.message.monitorRunFailed'))
|
||||
// Handle timeout gracefully - analysis may still be running in background
|
||||
if (e.code === 'ECONNABORTED' || e.message?.includes('timeout')) {
|
||||
this.$notification.warning({
|
||||
message: this.$t('portfolio.monitors.timeoutTitle'),
|
||||
description: this.$t('portfolio.monitors.timeoutDesc'),
|
||||
duration: 8
|
||||
})
|
||||
} else {
|
||||
this.$message.error(this.$t('portfolio.message.monitorRunFailed'))
|
||||
}
|
||||
} finally {
|
||||
this.runningMonitor = null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,26 @@
|
||||
|
||||
<!-- 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 class="toolbar-left">
|
||||
<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>
|
||||
<div class="toolbar-right">
|
||||
<a-input-search
|
||||
v-model="searchKeyword"
|
||||
:placeholder="$t('userManage.searchPlaceholder') || 'Search by username/email'"
|
||||
style="width: 280px"
|
||||
allowClear
|
||||
@search="handleSearch"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Table -->
|
||||
@@ -50,6 +62,22 @@
|
||||
<span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Credits Column -->
|
||||
<template slot="credits" slot-scope="text">
|
||||
<span class="credits-value">{{ formatCredits(text) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- VIP Column -->
|
||||
<template slot="vip_expires_at" slot-scope="text">
|
||||
<template v-if="text && isVipActive(text)">
|
||||
<a-tag color="gold">
|
||||
<a-icon type="crown" />
|
||||
{{ formatDate(text) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<template slot="action" slot-scope="text, record">
|
||||
<a-space>
|
||||
@@ -58,6 +86,16 @@
|
||||
<a-icon type="edit" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="$t('userManage.adjustCredits') || 'Adjust Credits'">
|
||||
<a-button type="link" size="small" @click="showCreditsModal(record)">
|
||||
<a-icon type="wallet" style="color: #722ed1" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="$t('userManage.setVip') || 'Set VIP'">
|
||||
<a-button type="link" size="small" @click="showVipModal(record)">
|
||||
<a-icon type="crown" style="color: #faad14" />
|
||||
</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" />
|
||||
@@ -185,11 +223,85 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Adjust Credits Modal -->
|
||||
<a-modal
|
||||
v-model="creditsModalVisible"
|
||||
:title="($t('userManage.adjustCredits') || 'Adjust Credits') + (creditsEditingUser ? ` - ${creditsEditingUser.username}` : '')"
|
||||
:confirmLoading="creditsLoading"
|
||||
@ok="handleSetCredits"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<div class="current-credits-info" v-if="creditsEditingUser">
|
||||
<span class="label">{{ $t('userManage.currentCredits') || 'Current Credits' }}:</span>
|
||||
<span class="value">{{ formatCredits(creditsEditingUser.credits) }}</span>
|
||||
</div>
|
||||
<a-form-item :label="$t('userManage.newCredits') || 'New Credits'">
|
||||
<a-input-number
|
||||
v-model="newCredits"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:placeholder="$t('userManage.enterCredits') || 'Enter new credits amount'"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="$t('userManage.remark') || 'Remark'">
|
||||
<a-input
|
||||
v-model="creditsRemark"
|
||||
:placeholder="$t('userManage.remarkPlaceholder') || 'Optional remark'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Set VIP Modal -->
|
||||
<a-modal
|
||||
v-model="vipModalVisible"
|
||||
:title="($t('userManage.setVip') || 'Set VIP') + (vipEditingUser ? ` - ${vipEditingUser.username}` : '')"
|
||||
:confirmLoading="vipLoading"
|
||||
@ok="handleSetVip"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<div class="current-vip-info" v-if="vipEditingUser && vipEditingUser.vip_expires_at">
|
||||
<span class="label">{{ $t('userManage.currentVip') || 'Current VIP' }}:</span>
|
||||
<span class="value" :class="isVipActive(vipEditingUser.vip_expires_at) ? 'active' : 'expired'">
|
||||
{{ isVipActive(vipEditingUser.vip_expires_at)
|
||||
? ($t('userManage.vipActive') || 'Active') + ` (${formatDate(vipEditingUser.vip_expires_at)})`
|
||||
: ($t('userManage.vipExpired') || 'Expired') }}
|
||||
</span>
|
||||
</div>
|
||||
<a-form-item :label="$t('userManage.vipDays') || 'VIP Days'">
|
||||
<a-select v-model="vipDays" style="width: 100%">
|
||||
<a-select-option :value="0">{{ $t('userManage.cancelVip') || 'Cancel VIP' }}</a-select-option>
|
||||
<a-select-option :value="7">7 {{ $t('userManage.days') || 'days' }}</a-select-option>
|
||||
<a-select-option :value="30">30 {{ $t('userManage.days') || 'days' }}</a-select-option>
|
||||
<a-select-option :value="90">90 {{ $t('userManage.days') || 'days' }}</a-select-option>
|
||||
<a-select-option :value="180">180 {{ $t('userManage.days') || 'days' }}</a-select-option>
|
||||
<a-select-option :value="365">365 {{ $t('userManage.days') || 'days' }}</a-select-option>
|
||||
<a-select-option :value="-1">{{ $t('userManage.customDate') || 'Custom Date' }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="vipDays === -1" :label="$t('userManage.vipExpiresAt') || 'VIP Expires At'">
|
||||
<a-date-picker
|
||||
v-model="vipCustomDate"
|
||||
showTime
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="$t('userManage.remark') || 'Remark'">
|
||||
<a-input
|
||||
v-model="vipRemark"
|
||||
:placeholder="$t('userManage.remarkPlaceholder') || 'Optional remark'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles } from '@/api/user'
|
||||
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles, setUserCredits, setUserVip } from '@/api/user'
|
||||
import { baseMixin } from '@/store/app-mixin'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
@@ -201,6 +313,7 @@ export default {
|
||||
loading: false,
|
||||
users: [],
|
||||
roles: [],
|
||||
searchKeyword: '',
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
@@ -214,7 +327,20 @@ export default {
|
||||
// Reset Password Modal
|
||||
resetPasswordVisible: false,
|
||||
resetPasswordLoading: false,
|
||||
resetPasswordUserId: null
|
||||
resetPasswordUserId: null,
|
||||
// Credits Modal
|
||||
creditsModalVisible: false,
|
||||
creditsLoading: false,
|
||||
creditsEditingUser: null,
|
||||
newCredits: 0,
|
||||
creditsRemark: '',
|
||||
// VIP Modal
|
||||
vipModalVisible: false,
|
||||
vipLoading: false,
|
||||
vipEditingUser: null,
|
||||
vipDays: 30,
|
||||
vipCustomDate: null,
|
||||
vipRemark: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -240,7 +366,7 @@ export default {
|
||||
{
|
||||
title: this.$t('userManage.nickname') || 'Nickname',
|
||||
dataIndex: 'nickname',
|
||||
width: 120
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: this.$t('userManage.email') || 'Email',
|
||||
@@ -250,25 +376,37 @@ export default {
|
||||
{
|
||||
title: this.$t('userManage.role') || 'Role',
|
||||
dataIndex: 'role',
|
||||
width: 100,
|
||||
width: 90,
|
||||
scopedSlots: { customRender: 'role' }
|
||||
},
|
||||
{
|
||||
title: this.$t('userManage.credits') || 'Credits',
|
||||
dataIndex: 'credits',
|
||||
width: 100,
|
||||
scopedSlots: { customRender: 'credits' }
|
||||
},
|
||||
{
|
||||
title: 'VIP',
|
||||
dataIndex: 'vip_expires_at',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'vip_expires_at' }
|
||||
},
|
||||
{
|
||||
title: this.$t('userManage.status') || 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
width: 90,
|
||||
scopedSlots: { customRender: 'status' }
|
||||
},
|
||||
{
|
||||
title: this.$t('userManage.lastLogin') || 'Last Login',
|
||||
dataIndex: 'last_login_at',
|
||||
width: 160,
|
||||
width: 150,
|
||||
scopedSlots: { customRender: 'last_login_at' }
|
||||
},
|
||||
{
|
||||
title: this.$t('common.actions') || 'Actions',
|
||||
dataIndex: 'action',
|
||||
width: 150,
|
||||
width: 180,
|
||||
scopedSlots: { customRender: 'action' }
|
||||
}
|
||||
]
|
||||
@@ -288,7 +426,8 @@ export default {
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: this.pagination.current,
|
||||
page_size: this.pagination.pageSize
|
||||
page_size: this.pagination.pageSize,
|
||||
search: this.searchKeyword || ''
|
||||
})
|
||||
if (res.code === 1) {
|
||||
this.users = res.data.items || []
|
||||
@@ -303,6 +442,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch () {
|
||||
this.pagination.current = 1
|
||||
this.loadUsers()
|
||||
},
|
||||
|
||||
async loadRoles () {
|
||||
try {
|
||||
const res = await getRoles()
|
||||
@@ -454,6 +598,99 @@ export default {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
|
||||
return date.toLocaleString()
|
||||
},
|
||||
|
||||
formatCredits (credits) {
|
||||
if (!credits && credits !== 0) return '0'
|
||||
return Number(credits).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
},
|
||||
|
||||
formatDate (dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
|
||||
isVipActive (expiresAt) {
|
||||
if (!expiresAt) return false
|
||||
return new Date(expiresAt) > new Date()
|
||||
},
|
||||
|
||||
// Credits Modal
|
||||
showCreditsModal (record) {
|
||||
this.creditsEditingUser = record
|
||||
this.newCredits = parseFloat(record.credits) || 0
|
||||
this.creditsRemark = ''
|
||||
this.creditsModalVisible = true
|
||||
},
|
||||
|
||||
async handleSetCredits () {
|
||||
if (this.newCredits < 0) {
|
||||
this.$message.error(this.$t('userManage.creditsNonNegative') || 'Credits cannot be negative')
|
||||
return
|
||||
}
|
||||
|
||||
this.creditsLoading = true
|
||||
try {
|
||||
const res = await setUserCredits({
|
||||
user_id: this.creditsEditingUser.id,
|
||||
credits: this.newCredits,
|
||||
remark: this.creditsRemark
|
||||
})
|
||||
if (res.code === 1) {
|
||||
this.$message.success(res.msg || 'Credits updated successfully')
|
||||
this.creditsModalVisible = false
|
||||
this.loadUsers()
|
||||
} else {
|
||||
this.$message.error(res.msg || 'Update failed')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('Update failed')
|
||||
} finally {
|
||||
this.creditsLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// VIP Modal
|
||||
showVipModal (record) {
|
||||
this.vipEditingUser = record
|
||||
this.vipDays = 30
|
||||
this.vipCustomDate = null
|
||||
this.vipRemark = ''
|
||||
this.vipModalVisible = true
|
||||
},
|
||||
|
||||
async handleSetVip () {
|
||||
const data = {
|
||||
user_id: this.vipEditingUser.id,
|
||||
remark: this.vipRemark
|
||||
}
|
||||
|
||||
if (this.vipDays === -1) {
|
||||
if (!this.vipCustomDate) {
|
||||
this.$message.error(this.$t('userManage.selectDate') || 'Please select a date')
|
||||
return
|
||||
}
|
||||
data.vip_expires_at = this.vipCustomDate.toISOString()
|
||||
} else {
|
||||
data.vip_days = this.vipDays
|
||||
}
|
||||
|
||||
this.vipLoading = true
|
||||
try {
|
||||
const res = await setUserVip(data)
|
||||
if (res.code === 1) {
|
||||
this.$message.success(res.msg || 'VIP status updated successfully')
|
||||
this.vipModalVisible = false
|
||||
this.loadUsers()
|
||||
} else {
|
||||
this.$message.error(res.msg || 'Update failed')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('Update failed')
|
||||
} finally {
|
||||
this.vipLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,7 +732,18 @@ export default {
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-table-card {
|
||||
@@ -552,5 +800,41 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Credits value style
|
||||
.credits-value {
|
||||
font-weight: 600;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
// Current info styles
|
||||
.current-credits-info,
|
||||
.current-vip-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
|
||||
&.active {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.expired {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user