2026-01-14 05:29:55 +08:00
|
|
|
<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">
|
2026-01-14 20:42:10 +08:00
|
|
|
<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>
|
2026-01-14 05:29:55 +08:00
|
|
|
</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>
|
|
|
|
|
|
2026-01-14 20:42:10 +08:00
|
|
|
<!-- 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>
|
|
|
|
|
|
2026-01-14 05:29:55 +08:00
|
|
|
<!-- 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>
|
2026-01-14 20:42:10 +08:00
|
|
|
<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>
|
2026-01-14 05:29:55 +08:00
|
|
|
<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>
|
2026-01-14 20:42:10 +08:00
|
|
|
|
|
|
|
|
<!-- 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>
|
2026-01-14 05:29:55 +08:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-01-14 20:42:10 +08:00
|
|
|
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles, setUserCredits, setUserVip } from '@/api/user'
|
2026-01-14 05:29:55 +08:00
|
|
|
import { baseMixin } from '@/store/app-mixin'
|
|
|
|
|
import { mapGetters } from 'vuex'
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
name: 'UserManage',
|
|
|
|
|
mixins: [baseMixin],
|
|
|
|
|
data () {
|
|
|
|
|
return {
|
|
|
|
|
loading: false,
|
|
|
|
|
users: [],
|
|
|
|
|
roles: [],
|
2026-01-14 20:42:10 +08:00
|
|
|
searchKeyword: '',
|
2026-01-14 05:29:55 +08:00
|
|
|
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,
|
2026-01-14 20:42:10 +08:00
|
|
|
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: ''
|
2026-01-14 05:29:55 +08:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
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',
|
2026-01-14 20:42:10 +08:00
|
|
|
width: 100
|
2026-01-14 05:29:55 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: this.$t('userManage.email') || 'Email',
|
|
|
|
|
dataIndex: 'email',
|
|
|
|
|
width: 180
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: this.$t('userManage.role') || 'Role',
|
|
|
|
|
dataIndex: 'role',
|
2026-01-14 20:42:10 +08:00
|
|
|
width: 90,
|
2026-01-14 05:29:55 +08:00
|
|
|
scopedSlots: { customRender: 'role' }
|
|
|
|
|
},
|
2026-01-14 20:42:10 +08:00
|
|
|
{
|
|
|
|
|
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' }
|
|
|
|
|
},
|
2026-01-14 05:29:55 +08:00
|
|
|
{
|
|
|
|
|
title: this.$t('userManage.status') || 'Status',
|
|
|
|
|
dataIndex: 'status',
|
2026-01-14 20:42:10 +08:00
|
|
|
width: 90,
|
2026-01-14 05:29:55 +08:00
|
|
|
scopedSlots: { customRender: 'status' }
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: this.$t('userManage.lastLogin') || 'Last Login',
|
|
|
|
|
dataIndex: 'last_login_at',
|
2026-01-14 20:42:10 +08:00
|
|
|
width: 150,
|
2026-01-14 05:29:55 +08:00
|
|
|
scopedSlots: { customRender: 'last_login_at' }
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: this.$t('common.actions') || 'Actions',
|
|
|
|
|
dataIndex: 'action',
|
2026-01-14 20:42:10 +08:00
|
|
|
width: 180,
|
2026-01-14 05:29:55 +08:00
|
|
|
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,
|
2026-01-14 20:42:10 +08:00
|
|
|
page_size: this.pagination.pageSize,
|
|
|
|
|
search: this.searchKeyword || ''
|
2026-01-14 05:29:55 +08:00
|
|
|
})
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-14 20:42:10 +08:00
|
|
|
handleSearch () {
|
|
|
|
|
this.pagination.current = 1
|
|
|
|
|
this.loadUsers()
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-14 05:29:55 +08:00
|
|
|
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()
|
2026-01-14 20:42:10 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-01-14 05:29:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</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;
|
2026-01-14 20:42:10 +08:00
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
.toolbar-left {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toolbar-right {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
2026-01-14 05:29:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-14 20:42:10 +08:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-14 05:29:55 +08:00
|
|
|
}
|
|
|
|
|
</style>
|