Remove account settings remnants
This commit is contained in:
@@ -6806,7 +6806,7 @@
|
||||
"badgeVariants": null,
|
||||
"id": "ys46Xxzw3B4lMj83oaUUx",
|
||||
"sort": 0,
|
||||
"is_deleted": false,
|
||||
"is_deleted": true,
|
||||
"sys_create_datetime": "2026-01-02T04:03:16.317341",
|
||||
"sys_update_datetime": "2026-01-02T04:04:55.264446",
|
||||
"sys_creator_id": null,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* API Token (Personal Access Token) 管理
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace ApiTokenApi {
|
||||
export interface TokenItem {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
expires_at?: null | string;
|
||||
last_used_at?: null | string;
|
||||
description?: null | string;
|
||||
is_active: boolean;
|
||||
sys_create_datetime?: null | string;
|
||||
}
|
||||
|
||||
export interface CreateTokenRequest {
|
||||
name: string;
|
||||
expires_at?: null | string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateTokenResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
token_prefix: string;
|
||||
expires_at?: null | string;
|
||||
description?: null | string;
|
||||
sys_create_datetime?: null | string;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiTokenListApi() {
|
||||
return requestClient.get<ApiTokenApi.TokenItem[]>('/api/core/api-tokens');
|
||||
}
|
||||
|
||||
export async function createApiTokenApi(data: ApiTokenApi.CreateTokenRequest) {
|
||||
return requestClient.post<ApiTokenApi.CreateTokenResponse>(
|
||||
'/api/core/api-tokens',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeApiTokenApi(tokenId: string) {
|
||||
return requestClient.delete(`/api/core/api-tokens/${tokenId}`);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 设备管理 API
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace DeviceApi {
|
||||
/** 设备信息 */
|
||||
export interface DeviceInfo {
|
||||
device_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
browser_type?: string;
|
||||
os_type?: string;
|
||||
ip_address?: string;
|
||||
last_active_time?: string;
|
||||
is_current: boolean;
|
||||
is_online: boolean;
|
||||
}
|
||||
|
||||
/** 设备列表响应 */
|
||||
export interface DeviceListResponse {
|
||||
current_device?: DeviceInfo;
|
||||
online_devices: DeviceInfo[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/** 设备重命名请求 */
|
||||
export interface DeviceRenameRequest {
|
||||
device_name: string;
|
||||
}
|
||||
|
||||
/** 统计信息响应 */
|
||||
export interface StatisticsResponse {
|
||||
online_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备列表
|
||||
*/
|
||||
export async function getDeviceListApi() {
|
||||
return requestClient.get<DeviceApi.DeviceListResponse>('/api/core/devices');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制登出指定设备
|
||||
*/
|
||||
export async function logoutDeviceApi(deviceId: string) {
|
||||
return requestClient.delete(`/api/core/devices/${deviceId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出其他所有设备
|
||||
*/
|
||||
export async function logoutOtherDevicesApi() {
|
||||
return requestClient.delete('/api/core/devices/logout-others');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名设备
|
||||
*/
|
||||
export async function renameDeviceApi(
|
||||
deviceId: string,
|
||||
data: DeviceApi.DeviceRenameRequest,
|
||||
) {
|
||||
return requestClient.post(`/api/core/devices/${deviceId}/rename`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备统计信息
|
||||
*/
|
||||
export async function getDeviceStatisticsApi() {
|
||||
return requestClient.get<DeviceApi.StatisticsResponse>(
|
||||
'/api/core/devices/statistics',
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './announcement';
|
||||
export * from './api-token';
|
||||
export * from './application';
|
||||
export * from './auth';
|
||||
export * from './data-source';
|
||||
|
||||
@@ -23,7 +23,6 @@ const modules = import.meta.glob([
|
||||
'./langs/*/about.json',
|
||||
'./langs/*/ai-platform.json',
|
||||
'./langs/*/announcement.json',
|
||||
'./langs/*/apiToken.json',
|
||||
'./langs/*/authentication.json',
|
||||
'./langs/*/common.json',
|
||||
'./langs/*/menu.json',
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"passwordLengthHint": "Password length must be 6-20 characters"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"title": "API Token",
|
||||
"deviceManagement": "Device Management",
|
||||
"description": "API Tokens can be used to access system resources via API. Please keep your tokens secure.",
|
||||
"createToken": "Create Token",
|
||||
"tokenName": "Token Name",
|
||||
"tokenNamePlaceholder": "Enter token name, e.g., CI/CD Deployment",
|
||||
"expirationDate": "Expiration Date",
|
||||
"neverExpiresHint": "Leave empty for no expiration",
|
||||
"tokenDescription": "Description",
|
||||
"tokenDescriptionPlaceholder": "Optional, describe the purpose of this token",
|
||||
"neverExpires": "Never expires",
|
||||
"expired": "Expired",
|
||||
"expiresSoon": "Expires in {0} days",
|
||||
"createdAt": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"revokeToken": "Revoke Token",
|
||||
"revokeTitle": "Revoke Token",
|
||||
"revokeConfirm": "Are you sure you want to revoke token \"{0}\"? This action cannot be undone.",
|
||||
"confirmRevoke": "Confirm Revoke",
|
||||
"revokeSuccess": "Token revoked",
|
||||
"tokenCreated": "Token Created Successfully",
|
||||
"tokenWarning": "Please copy your token now. You won't be able to see it again after closing this dialog.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copySuccess": "Token copied to clipboard",
|
||||
"copyError": "Copy failed, please copy manually",
|
||||
"empty": "No API Tokens yet. Click \"Create Token\" to generate your first token.",
|
||||
"nameRequired": "Please enter a token name",
|
||||
"loadError": "Failed to load token list",
|
||||
"createError": "Failed to create token",
|
||||
"days7": "7 days",
|
||||
"days30": "30 days",
|
||||
"days60": "60 days",
|
||||
"days90": "90 days",
|
||||
"days365": "1 year"
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"roleManagement": "Role Permission",
|
||||
"userCenter": "User Center",
|
||||
"loginLog": "Login Log",
|
||||
"accountSettings": "Account Settings",
|
||||
"databaseManagement": "Database Management",
|
||||
"dbManagement": "DB Management",
|
||||
"databaseConnection": "Database Connections",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"resetPasswordConfirm": "Are you sure to reset password for user \"{0}\"?\nThe password will be reset to: admin123",
|
||||
"resetPasswordSuccess": "Password reset successfully for user \"{0}\"",
|
||||
"resetPasswordError": "Failed to reset password",
|
||||
"accountSettings": "Account Settings",
|
||||
"basicInfo": "Basic Information",
|
||||
"changePassword": "Change Password",
|
||||
"oldPassword": "Current Password",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"passwordLengthHint": "密码长度为 6-20 个字符"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"title": "API Token",
|
||||
"deviceManagement": "设备管理",
|
||||
"description": "API Token 可用于通过 API 访问系统资源,请妥善保管你的令牌。",
|
||||
"createToken": "创建令牌",
|
||||
"tokenName": "令牌名称",
|
||||
"tokenNamePlaceholder": "请输入令牌名称,例如:CI/CD 部署",
|
||||
"expirationDate": "过期时间",
|
||||
"neverExpiresHint": "不选择则永不过期",
|
||||
"tokenDescription": "描述",
|
||||
"tokenDescriptionPlaceholder": "可选,描述该令牌的用途",
|
||||
"neverExpires": "永不过期",
|
||||
"expired": "已过期",
|
||||
"expiresSoon": "{0} 天后过期",
|
||||
"createdAt": "创建时间",
|
||||
"lastUsed": "最后使用",
|
||||
"revokeToken": "撤销令牌",
|
||||
"revokeTitle": "撤销令牌",
|
||||
"revokeConfirm": "确定要撤销令牌「{0}」吗?撤销后将无法恢复。",
|
||||
"confirmRevoke": "确定撤销",
|
||||
"revokeSuccess": "令牌已撤销",
|
||||
"tokenCreated": "令牌创建成功",
|
||||
"tokenWarning": "请立即复制你的令牌,此令牌仅显示一次,关闭后将无法再次查看。",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copySuccess": "令牌已复制到剪贴板",
|
||||
"copyError": "复制失败,请手动复制",
|
||||
"empty": "暂无 API Token,点击「创建令牌」生成你的第一个令牌",
|
||||
"nameRequired": "请输入令牌名称",
|
||||
"loadError": "加载令牌列表失败",
|
||||
"createError": "创建令牌失败",
|
||||
"days7": "7 天",
|
||||
"days30": "30 天",
|
||||
"days60": "60 天",
|
||||
"days90": "90 天",
|
||||
"days365": "1 年"
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"roleManagement": "角色权限",
|
||||
"userCenter": "用户中心",
|
||||
"loginLog": "登录日志",
|
||||
"accountSettings": "账号设置",
|
||||
"databaseManagement": "数据库管理",
|
||||
"dbManagement": "DB管理",
|
||||
"databaseConnection": "数据库连接",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"resetPasswordConfirm": "确定要重置用户{0}的密码吗?\n密码将重置为:admin123",
|
||||
"resetPasswordSuccess": "用户{0}的密码重置成功",
|
||||
"resetPasswordError": "重置密码失败",
|
||||
"accountSettings": "账号设置",
|
||||
"basicInfo": "基本信息",
|
||||
"changePassword": "修改密码",
|
||||
"oldPassword": "当前密码",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"passwordLengthHint": "密碼長度為 6-20 個字符"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"title": "API Token",
|
||||
"deviceManagement": "設備管理",
|
||||
"description": "API Token 可用於通過 API 訪問系統資源,請妥善保管你的令牌。",
|
||||
"createToken": "建立令牌",
|
||||
"tokenName": "令牌名稱",
|
||||
"tokenNamePlaceholder": "請輸入令牌名稱,例如:CI/CD 部署",
|
||||
"expirationDate": "過期時間",
|
||||
"neverExpiresHint": "不選擇則永不過期",
|
||||
"tokenDescription": "描述",
|
||||
"tokenDescriptionPlaceholder": "可選,描述該令牌的用途",
|
||||
"neverExpires": "永不過期",
|
||||
"expired": "已過期",
|
||||
"expiresSoon": "{0} 天後過期",
|
||||
"createdAt": "建立時間",
|
||||
"lastUsed": "最後使用",
|
||||
"revokeToken": "撤銷令牌",
|
||||
"revokeTitle": "撤銷令牌",
|
||||
"revokeConfirm": "確定要撤銷令牌「{0}」嗎?撤銷後將無法恢復。",
|
||||
"confirmRevoke": "確定撤銷",
|
||||
"revokeSuccess": "令牌已撤銷",
|
||||
"tokenCreated": "令牌建立成功",
|
||||
"tokenWarning": "請立即複製你的令牌,此令牌僅顯示一次,關閉後將無法再次查看。",
|
||||
"copy": "複製",
|
||||
"copied": "已複製",
|
||||
"copySuccess": "令牌已複製到剪貼簿",
|
||||
"copyError": "複製失敗,請手動複製",
|
||||
"empty": "暫無 API Token,點擊「建立令牌」生成你的第一個令牌",
|
||||
"nameRequired": "請輸入令牌名稱",
|
||||
"loadError": "載入令牌列表失敗",
|
||||
"createError": "建立令牌失敗",
|
||||
"days7": "7 天",
|
||||
"days30": "30 天",
|
||||
"days60": "60 天",
|
||||
"days90": "90 天",
|
||||
"days365": "1 年"
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"roleManagement": "角色權限",
|
||||
"userCenter": "用戶中心",
|
||||
"loginLog": "登錄日誌",
|
||||
"accountSettings": "賬號設置",
|
||||
"databaseManagement": "數據庫管理",
|
||||
"dbManagement": "DB管理",
|
||||
"databaseConnection": "資料庫連接",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"resetPasswordConfirm": "確定要重置用戶 \"{0}\" 的密碼嗎?\n密碼將重置為:admin123",
|
||||
"resetPasswordSuccess": "用戶 \"{0}\" 的密碼重置成功",
|
||||
"resetPasswordError": "重置密碼失敗",
|
||||
"accountSettings": "賬號設置",
|
||||
"basicInfo": "基本信息",
|
||||
"changePassword": "修改密碼",
|
||||
"oldPassword": "當前密碼",
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ApiTokenApi } from '#/api/core/api-token';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { Copy, KeyRound, Plus, Trash2 } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElEmpty,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
createApiTokenApi,
|
||||
getApiTokenListApi,
|
||||
revokeApiTokenApi,
|
||||
} from '#/api/core/api-token';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
defineOptions({ name: 'ApiTokenManagement' });
|
||||
|
||||
const tokens = ref<ApiTokenApi.TokenItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const createDialogVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createForm = ref<ApiTokenApi.CreateTokenRequest>({
|
||||
name: '',
|
||||
expires_at: null,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const createdToken = ref<null | string>(null);
|
||||
const showTokenDialog = ref(false);
|
||||
const tokenCopied = ref(false);
|
||||
|
||||
async function loadTokens() {
|
||||
loading.value = true;
|
||||
try {
|
||||
tokens.value = await getApiTokenListApi();
|
||||
} catch {
|
||||
ElMessage.error($t('apiToken.loadError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
createForm.value = { name: '', expires_at: null, description: '' };
|
||||
createDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.name.trim()) {
|
||||
ElMessage.warning($t('apiToken.nameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const payload: ApiTokenApi.CreateTokenRequest = {
|
||||
name: createForm.value.name.trim(),
|
||||
description: createForm.value.description || undefined,
|
||||
};
|
||||
if (createForm.value.expires_at) {
|
||||
payload.expires_at = new Date(
|
||||
createForm.value.expires_at,
|
||||
).toISOString();
|
||||
}
|
||||
const result = await createApiTokenApi(payload);
|
||||
createdToken.value = result.token;
|
||||
tokenCopied.value = false;
|
||||
createDialogVisible.value = false;
|
||||
showTokenDialog.value = true;
|
||||
await loadTokens();
|
||||
} catch {
|
||||
ElMessage.error($t('apiToken.createError'));
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(token: ApiTokenApi.TokenItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
$t('apiToken.revokeConfirm', [token.name]),
|
||||
$t('apiToken.revokeTitle'),
|
||||
{ type: 'warning', confirmButtonText: $t('apiToken.confirmRevoke') },
|
||||
);
|
||||
await revokeApiTokenApi(token.id);
|
||||
ElMessage.success($t('apiToken.revokeSuccess'));
|
||||
await loadTokens();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!createdToken.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken.value);
|
||||
tokenCopied.value = true;
|
||||
ElMessage.success($t('apiToken.copySuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('apiToken.copyError'));
|
||||
}
|
||||
}
|
||||
|
||||
function getExpirationStatus(token: ApiTokenApi.TokenItem) {
|
||||
if (!token.expires_at) {
|
||||
return { label: $t('apiToken.neverExpires'), type: 'success' as const };
|
||||
}
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(token.expires_at);
|
||||
if (expiresAt < now) {
|
||||
return { label: $t('apiToken.expired'), type: 'danger' as const };
|
||||
}
|
||||
const daysLeft = Math.ceil(
|
||||
(expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (daysLeft <= 7) {
|
||||
return {
|
||||
label: $t('apiToken.expiresSoon', [daysLeft]),
|
||||
type: 'warning' as const,
|
||||
};
|
||||
}
|
||||
return { label: token.expires_at, type: 'info' as const };
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: null | string) {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
const expirationShortcuts = computed(() => [
|
||||
{
|
||||
text: $t('apiToken.days7'),
|
||||
value: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 7);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
{
|
||||
text: $t('apiToken.days30'),
|
||||
value: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
{
|
||||
text: $t('apiToken.days60'),
|
||||
value: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 60);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
{
|
||||
text: $t('apiToken.days90'),
|
||||
value: () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 90);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
{
|
||||
text: $t('apiToken.days365'),
|
||||
value: () => {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return d;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
loadTokens();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="api-token-management">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('apiToken.description') }}
|
||||
</p>
|
||||
<ElButton type="primary" @click="openCreateDialog">
|
||||
<Plus class="mr-1 h-4 w-4" />
|
||||
{{ $t('apiToken.createToken') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- Token List -->
|
||||
<div v-if="tokens.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color)] p-4 transition-colors hover:bg-[var(--el-fill-color-light)]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyRound class="h-4 w-4 text-[var(--el-color-primary)]" />
|
||||
<span class="font-medium text-[var(--el-text-color-primary)]">
|
||||
{{ token.name }}
|
||||
</span>
|
||||
<ElTag
|
||||
:type="getExpirationStatus(token).type"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ getExpirationStatus(token).label }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex items-center gap-4 text-xs text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<span>{{ token.token_prefix }}</span>
|
||||
<span v-if="token.description">{{ token.description }}</span>
|
||||
<span>
|
||||
{{ $t('apiToken.createdAt') }}:
|
||||
{{ formatDate(token.sys_create_datetime) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ $t('apiToken.lastUsed') }}:
|
||||
{{ formatDate(token.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElTooltip :content="$t('apiToken.revokeToken')" placement="top">
|
||||
<ElButton
|
||||
type="danger"
|
||||
text
|
||||
circle
|
||||
@click="handleRevoke(token)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<ElEmpty v-else-if="!loading" :description="$t('apiToken.empty')" />
|
||||
|
||||
<!-- Create Token Dialog -->
|
||||
<ZqDialog
|
||||
v-model="createDialogVisible"
|
||||
:title="$t('apiToken.createToken')"
|
||||
:confirm-loading="createLoading"
|
||||
width="480px"
|
||||
@confirm="handleCreate"
|
||||
>
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem :label="$t('apiToken.tokenName')" required>
|
||||
<ElInput
|
||||
v-model="createForm.name"
|
||||
:placeholder="$t('apiToken.tokenNamePlaceholder')"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('apiToken.expirationDate')">
|
||||
<ElDatePicker
|
||||
v-model="createForm.expires_at"
|
||||
type="datetime"
|
||||
:placeholder="$t('apiToken.neverExpiresHint')"
|
||||
:shortcuts="expirationShortcuts"
|
||||
:disabled-date="(date: Date) => date < new Date()"
|
||||
class="w-full"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('apiToken.tokenDescription')">
|
||||
<ElInput
|
||||
v-model="createForm.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="$t('apiToken.tokenDescriptionPlaceholder')"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ZqDialog>
|
||||
|
||||
<!-- Token Display Dialog -->
|
||||
<ZqDialog
|
||||
v-model="showTokenDialog"
|
||||
:title="$t('apiToken.tokenCreated')"
|
||||
width="560px"
|
||||
:show-footer="false"
|
||||
>
|
||||
<ElAlert
|
||||
:title="$t('apiToken.tokenWarning')"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg bg-[var(--el-fill-color)] p-3"
|
||||
>
|
||||
<code
|
||||
class="flex-1 break-all text-sm text-[var(--el-text-color-primary)]"
|
||||
>
|
||||
{{ createdToken }}
|
||||
</code>
|
||||
<ElButton type="primary" size="small" @click="copyToken">
|
||||
<Copy class="mr-1 h-3.5 w-3.5" />
|
||||
{{ tokenCopied ? $t('apiToken.copied') : $t('apiToken.copy') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ZqDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
|
||||
/**
|
||||
* 获取性别选项
|
||||
*/
|
||||
export function getGenderOptions() {
|
||||
return [
|
||||
{ label: $t('user.unknown'), value: 0 },
|
||||
{ label: $t('user.male'), value: 1 },
|
||||
{ label: $t('user.female'), value: 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基本信息表单配置
|
||||
*/
|
||||
export function getProfileFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: $t('user.userName'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(2, $t('ui.formRules.minLength', [$t('user.userName'), 2]))
|
||||
.max(64, $t('ui.formRules.maxLength', [$t('user.userName'), 64]))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
component: 'ImageSelector',
|
||||
componentProps: {
|
||||
enableCrop: true,
|
||||
cropShape: 'circle',
|
||||
maxSize: 2,
|
||||
placeholder: $t('user.selectAvatar'),
|
||||
},
|
||||
fieldName: 'avatar',
|
||||
label: $t('user.avatar'),
|
||||
help: $t('user.avatarHelp'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'email',
|
||||
label: $t('user.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.email($t('user.emailFormatError'))
|
||||
.max(255, $t('ui.formRules.maxLength', [$t('user.email'), 255]))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'mobile',
|
||||
label: $t('user.mobile'),
|
||||
rules: z
|
||||
.string()
|
||||
.regex(/^1[3-9]\d{9}$/, $t('user.mobileFormatError'))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: getGenderOptions(),
|
||||
isButton: true,
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'gender',
|
||||
label: $t('user.gender'),
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
placeholder: $t('user.selectBirthday'),
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
fieldName: 'birthday',
|
||||
label: $t('user.birthday'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'city',
|
||||
label: $t('user.city'),
|
||||
rules: z
|
||||
.string()
|
||||
.max(100, $t('ui.formRules.maxLength', [$t('user.city'), 100]))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'address',
|
||||
label: $t('user.address'),
|
||||
rules: z
|
||||
.string()
|
||||
.max(200, $t('ui.formRules.maxLength', [$t('user.address'), 200]))
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: $t('user.bioPlaceholder'),
|
||||
rows: 3,
|
||||
},
|
||||
fieldName: 'bio',
|
||||
label: $t('user.bio'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取密码修改表单配置
|
||||
*/
|
||||
export function getPasswordFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
type: 'password',
|
||||
showPassword: true,
|
||||
placeholder: $t('user.oldPasswordPlaceholder'),
|
||||
},
|
||||
fieldName: 'old_password',
|
||||
label: $t('user.oldPassword'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, $t('ui.formRules.required', [$t('user.oldPassword')])),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
type: 'password',
|
||||
showPassword: true,
|
||||
placeholder: $t('user.newPasswordPlaceholder'),
|
||||
},
|
||||
fieldName: 'new_password',
|
||||
label: $t('user.newPassword'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(6, $t('ui.formRules.minLength', [$t('user.newPassword'), 6]))
|
||||
.max(20, $t('ui.formRules.maxLength', [$t('user.newPassword'), 20])),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
type: 'password',
|
||||
showPassword: true,
|
||||
placeholder: $t('user.confirmPasswordPlaceholder'),
|
||||
},
|
||||
fieldName: 'confirm_password',
|
||||
label: $t('user.confirmPassword'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, $t('ui.formRules.required', [$t('user.confirmPassword')])),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DeviceApi } from '#/api/core/device';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Monitor, Smartphone } from '@vben/icons';
|
||||
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
|
||||
interface Props {
|
||||
device: DeviceApi.DeviceInfo;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'rename', device: DeviceApi.DeviceInfo): void;
|
||||
(e: 'logout', device: DeviceApi.DeviceInfo): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 设备显示名称
|
||||
const deviceDisplayName = computed(() => {
|
||||
if (props.device.device_name) {
|
||||
return props.device.device_name;
|
||||
}
|
||||
return `${props.device.browser_type || 'Unknown'} · ${props.device.os_type || 'Unknown'}`;
|
||||
});
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days < 30) return `${days}天前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
} catch {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
emit('rename', props.device);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
emit('logout', props.device);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="device-item border-b border-gray-100 pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 设备图标 -->
|
||||
<div class="mt-1">
|
||||
<Smartphone
|
||||
v-if="device.device_type === 'mobile'"
|
||||
class="size-6 text-gray-600"
|
||||
/>
|
||||
<Monitor
|
||||
v-else-if="device.device_type === 'tablet'"
|
||||
class="size-6 text-gray-600"
|
||||
/>
|
||||
<Monitor v-else class="size-6 text-gray-600" />
|
||||
</div>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-base font-semibold">
|
||||
{{ deviceDisplayName }}
|
||||
</span>
|
||||
<ElTag v-if="isCurrent" type="success" size="small">
|
||||
当前设备
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-if="device.is_online"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
在线
|
||||
</ElTag>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{{ device.browser_type }} · {{ device.os_type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span>IP: {{ device.ip_address }}</span>
|
||||
</div>
|
||||
<div v-if="device.last_active_time" class="flex items-center gap-4">
|
||||
<span>最后活跃: {{ formatTime(device.last_active_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2">
|
||||
<ElButton size="small" @click="handleRename"> 重命名 </ElButton>
|
||||
<ElButton
|
||||
v-if="!isCurrent"
|
||||
type="danger"
|
||||
size="small"
|
||||
plain
|
||||
@click="handleLogout"
|
||||
>
|
||||
强制登出
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-item {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.device-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,229 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DeviceApi } from '#/api/core/device';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { Monitor } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDialog,
|
||||
ElEmpty,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getDeviceListApi,
|
||||
logoutDeviceApi,
|
||||
logoutOtherDevicesApi,
|
||||
renameDeviceApi,
|
||||
} from '#/api/core/device';
|
||||
|
||||
import DeviceItem from './device-item.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const deviceList = reactive<DeviceApi.DeviceListResponse>({
|
||||
current_device: undefined,
|
||||
online_devices: [],
|
||||
total_count: 0,
|
||||
});
|
||||
|
||||
const renameDialogVisible = ref(false);
|
||||
const renameForm = reactive({
|
||||
device_id: '',
|
||||
device_name: '',
|
||||
});
|
||||
|
||||
// 加载设备列表
|
||||
async function loadDevices() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDeviceListApi();
|
||||
Object.assign(deviceList, res);
|
||||
} catch {
|
||||
ElMessage.error('加载设备列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名设备
|
||||
function handleRename(device: DeviceApi.DeviceInfo) {
|
||||
renameForm.device_id = device.device_id;
|
||||
renameForm.device_name = device.device_name || '';
|
||||
renameDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 确认重命名
|
||||
async function handleRenameConfirm() {
|
||||
if (!renameForm.device_name.trim()) {
|
||||
ElMessage.warning('请输入设备名称');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameDeviceApi(renameForm.device_id, {
|
||||
device_name: renameForm.device_name,
|
||||
});
|
||||
ElMessage.success('重命名成功');
|
||||
renameDialogVisible.value = false;
|
||||
await loadDevices();
|
||||
} catch {
|
||||
ElMessage.error('重命名失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 登出指定设备
|
||||
async function handleLogoutDevice(device: DeviceApi.DeviceInfo) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要强制登出该设备吗?该设备将在下次刷新时被踢出。`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
|
||||
await logoutDeviceApi(device.device_id);
|
||||
ElMessage.success('已强制登出该设备');
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登出所有其他设备
|
||||
async function handleLogoutAllDevices() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要登出所有其他设备吗?这将强制所有其他设备下次刷新时重新登录。`,
|
||||
'确认操作',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
|
||||
await logoutOtherDevicesApi();
|
||||
ElMessage.success('已登出所有其他设备');
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDevices();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-management">
|
||||
<!-- 统计信息 -->
|
||||
<ElCard class="mb-4">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-semibold">设备管理</span>
|
||||
<ElButton
|
||||
v-if="deviceList.online_devices.length > 0"
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="handleLogoutAllDevices"
|
||||
>
|
||||
登出所有其他设备
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-8 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">当前在线:</span>
|
||||
<span class="text-primary ml-2 text-xl font-bold">
|
||||
{{ deviceList.total_count }}
|
||||
</span>
|
||||
<span class="ml-1 text-gray-500">台设备</span>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 当前设备 -->
|
||||
<ElCard v-if="deviceList.current_device" class="mb-4">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Monitor class="mr-2 size-5 text-green-500" />
|
||||
<span class="font-semibold">当前设备 (你正在使用)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeviceItem
|
||||
:device="deviceList.current_device"
|
||||
:is-current="true"
|
||||
@rename="handleRename"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 其他在线设备 -->
|
||||
<ElCard v-if="deviceList.online_devices.length > 0">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Monitor class="mr-2 size-5 text-blue-500" />
|
||||
<span class="font-semibold">其他在线设备</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<DeviceItem
|
||||
v-for="device in deviceList.online_devices"
|
||||
:key="device.device_id"
|
||||
:device="device"
|
||||
@rename="handleRename"
|
||||
@logout="handleLogoutDevice"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 无其他设备提示 -->
|
||||
<ElEmpty
|
||||
v-if="!loading && deviceList.online_devices.length === 0"
|
||||
description="暂无其他设备登录"
|
||||
/>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<ElDialog v-model="renameDialogVisible" title="重命名设备" width="400px">
|
||||
<ElForm :model="renameForm" label-width="80px">
|
||||
<ElFormItem label="设备名称">
|
||||
<ElInput
|
||||
v-model="renameForm.device_name"
|
||||
placeholder="请输入设备名称,如:办公室电脑"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="renameDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleRenameConfirm"> 确定 </ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-management {
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,184 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { User } from '#/api/core';
|
||||
import type { CardListItem, CardListOptions } from '#/components/card-list';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElCard, ElMessage } from 'element-plus';
|
||||
|
||||
import { getCurrentUserProfileApi } from '#/api/core';
|
||||
import { CardList } from '#/components/card-list';
|
||||
|
||||
import ApiTokenManagement from './api-token-management.vue';
|
||||
import DeviceManagement from './device-management.vue';
|
||||
import PasswordForm from './modules/password-form.vue';
|
||||
import ProfileForm from './modules/profile-form.vue';
|
||||
|
||||
defineOptions({ name: 'AccountSettings' });
|
||||
|
||||
// 菜单项类型
|
||||
interface SettingMenuItem extends CardListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key: 'apiToken' | 'device' | 'password' | 'profile';
|
||||
}
|
||||
|
||||
// 菜单项数据
|
||||
const menuItems = ref<SettingMenuItem[]>([
|
||||
{
|
||||
id: 'profile',
|
||||
name: $t('user.basicInfo'),
|
||||
key: 'profile',
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
name: $t('user.changePassword'),
|
||||
key: 'password',
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
name: $t('apiToken.deviceManagement'),
|
||||
key: 'device',
|
||||
},
|
||||
{
|
||||
id: 'apiToken',
|
||||
name: $t('apiToken.title'),
|
||||
key: 'apiToken',
|
||||
},
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const selectedMenuId = ref<string>('profile');
|
||||
const userProfile = ref<null | User>(null);
|
||||
|
||||
// CardList 配置
|
||||
const cardListOptions: CardListOptions<SettingMenuItem> = {
|
||||
searchFields: [{ field: 'name' }],
|
||||
titleField: 'name',
|
||||
displayMode: 'center', // 一行居中显示
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理菜单选择
|
||||
*/
|
||||
function handleMenuSelect(id: string | undefined) {
|
||||
selectedMenuId.value = id || 'profile';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前用户信息
|
||||
*/
|
||||
async function loadUserProfile() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getCurrentUserProfileApi();
|
||||
userProfile.value = data;
|
||||
} catch {
|
||||
ElMessage.error($t('user.loadProfileError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表单成功回调
|
||||
*/
|
||||
async function handleFormSuccess() {
|
||||
await loadUserProfile();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="flex h-full">
|
||||
<!-- 左侧菜单 -->
|
||||
<div class="w-1/6">
|
||||
<CardList
|
||||
:items="menuItems"
|
||||
:selected-id="selectedMenuId"
|
||||
:options="cardListOptions"
|
||||
:loading="false"
|
||||
class="account-settings-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="text-sm font-medium">{{ item.name }}</div>
|
||||
</template>
|
||||
</CardList>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="flex-1">
|
||||
<ElCard shadow="never" class="h-full">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>
|
||||
{{
|
||||
selectedMenuId === 'profile'
|
||||
? $t('user.basicInfo')
|
||||
: selectedMenuId === 'password'
|
||||
? $t('user.changePassword')
|
||||
: selectedMenuId === 'device'
|
||||
? $t('apiToken.deviceManagement')
|
||||
: $t('apiToken.title')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 基本信息表单 -->
|
||||
<template v-if="selectedMenuId === 'profile'">
|
||||
<ProfileForm
|
||||
:user-profile="userProfile"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 修改密码表单 -->
|
||||
<template v-else-if="selectedMenuId === 'password'">
|
||||
<PasswordForm @success="handleFormSuccess" />
|
||||
</template>
|
||||
|
||||
<!-- 设备管理 -->
|
||||
<template v-else-if="selectedMenuId === 'device'">
|
||||
<DeviceManagement />
|
||||
</template>
|
||||
|
||||
<!-- API Token 管理 -->
|
||||
<template v-else-if="selectedMenuId === 'apiToken'">
|
||||
<ApiTokenManagement />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-settings-menu :deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 隐藏搜索和添加按钮 */
|
||||
.account-settings-menu :deep(.mb-4.flex) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-settings-menu :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UserChangePasswordInput } from '#/api/core';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { changePasswordApi } from '#/api/core';
|
||||
|
||||
defineOptions({ name: 'PasswordForm' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
// 密码修改表单数据
|
||||
const passwordForm = reactive<UserChangePasswordInput>({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
|
||||
// 表单引用
|
||||
const passwordFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 表单验证规则
|
||||
const passwordRules = {
|
||||
old_password: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('ui.formRules.required', [$t('user.oldPassword')]),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
new_password: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('ui.formRules.required', [$t('user.newPassword')]),
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: `${$t('ui.formRules.minLength', [
|
||||
$t('user.newPassword'),
|
||||
6,
|
||||
])},${$t('ui.formRules.maxLength', [$t('user.newPassword'), 20])}`,
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
confirm_password: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('ui.formRules.required', [$t('user.confirmPassword')]),
|
||||
trigger: 'blur',
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string, callback: Function) => {
|
||||
if (value === passwordForm.new_password) {
|
||||
callback();
|
||||
} else {
|
||||
callback(new Error($t('user.passwordNotMatch')));
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
async function handleChangePassword() {
|
||||
if (!passwordFormRef.value) return;
|
||||
|
||||
await passwordFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
// 验证新密码和确认密码是否一致
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
ElMessage.error($t('user.passwordNotMatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await changePasswordApi({ ...passwordForm });
|
||||
ElMessage.success($t('user.changePasswordSuccess'));
|
||||
// 清空表单
|
||||
Object.assign(passwordForm, {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
passwordFormRef.value?.resetFields();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('user.changePasswordError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
function handleReset() {
|
||||
passwordFormRef.value?.resetFields();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="120px"
|
||||
label-position="right"
|
||||
>
|
||||
<ElFormItem :label="$t('user.oldPassword')" prop="old_password">
|
||||
<ElInput
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
:placeholder="$t('user.oldPasswordPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.newPassword')" prop="new_password">
|
||||
<ElInput
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
:placeholder="$t('user.newPasswordPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('account-settings.passwordLengthHint') }}
|
||||
</p>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.confirmPassword')" prop="confirm_password">
|
||||
<ElInput
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
:placeholder="$t('user.confirmPasswordPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" :loading="loading" @click="handleChangePassword">
|
||||
{{ $t('common.save') }}
|
||||
</ElButton>
|
||||
<ElButton @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</template>
|
||||
@@ -1,293 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { User, UserProfileUpdateInput } from '#/api/core';
|
||||
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { patchUserProfileApi } from '#/api/core';
|
||||
import { ImageSelector } from '#/components/zq-form/image-selector';
|
||||
|
||||
defineOptions({ name: 'ProfileForm' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
userProfile: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
interface Props {
|
||||
userProfile?: null | User;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
// 基本信息表单数据
|
||||
const profileForm = reactive<UserProfileUpdateInput>({
|
||||
name: '',
|
||||
email: '',
|
||||
mobile: '',
|
||||
avatar: '',
|
||||
gender: 0,
|
||||
birthday: '',
|
||||
city: '',
|
||||
address: '',
|
||||
bio: '',
|
||||
});
|
||||
|
||||
// 表单引用
|
||||
const profileFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 性别选项
|
||||
const genderOptions = [
|
||||
{ label: $t('user.unknown'), value: 0 },
|
||||
{ label: $t('user.male'), value: 1 },
|
||||
{ label: $t('user.female'), value: 2 },
|
||||
];
|
||||
|
||||
// 表单验证规则
|
||||
const profileRules = {
|
||||
name: [
|
||||
{
|
||||
min: 2,
|
||||
max: 64,
|
||||
message: `${$t('ui.formRules.minLength', [$t('user.userName'), 2])},${$t(
|
||||
'ui.formRules.maxLength',
|
||||
[$t('user.userName'), 64],
|
||||
)}`,
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{
|
||||
type: 'email' as const,
|
||||
message: $t('user.emailFormatError'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: $t('user.mobileFormatError'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载用户信息
|
||||
*/
|
||||
function loadUserProfile() {
|
||||
if (props.userProfile) {
|
||||
Object.assign(profileForm, {
|
||||
name: props.userProfile.name || '',
|
||||
email: props.userProfile.email || '',
|
||||
mobile: props.userProfile.mobile || '',
|
||||
avatar: props.userProfile.avatar || '',
|
||||
gender: props.userProfile.gender ?? 0,
|
||||
birthday: props.userProfile.birthday || '',
|
||||
city: props.userProfile.city || '',
|
||||
address: props.userProfile.address || '',
|
||||
bio: props.userProfile.bio || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存基本信息
|
||||
*/
|
||||
async function handleSaveProfile() {
|
||||
if (!profileFormRef.value) return;
|
||||
|
||||
await profileFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await patchUserProfileApi({ ...profileForm });
|
||||
ElMessage.success($t('user.updateProfileSuccess'));
|
||||
// 通知父组件重新加载用户信息
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('user.updateProfileError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像上传成功回调
|
||||
*/
|
||||
function handleAvatarChange(value: string | string[] | undefined) {
|
||||
if (!value) {
|
||||
profileForm.avatar = '';
|
||||
return;
|
||||
}
|
||||
profileForm.avatar = Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
function handleReset() {
|
||||
loadUserProfile();
|
||||
}
|
||||
|
||||
// 监听 userProfile 变化,自动更新表单
|
||||
watch(
|
||||
() => props.userProfile,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
Object.assign(profileForm, {
|
||||
name: newVal.name || '',
|
||||
email: newVal.email || '',
|
||||
mobile: newVal.mobile || '',
|
||||
avatar: newVal.avatar || '',
|
||||
gender: newVal.gender ?? 0,
|
||||
birthday: newVal.birthday || '',
|
||||
city: newVal.city || '',
|
||||
address: newVal.address || '',
|
||||
bio: newVal.bio || '',
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 初始化加载用户信息
|
||||
loadUserProfile();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm
|
||||
ref="profileFormRef"
|
||||
:model="profileForm"
|
||||
:rules="profileRules"
|
||||
label-width="120px"
|
||||
label-position="right"
|
||||
>
|
||||
<!-- 头像区域 -->
|
||||
<ElFormItem :label="$t('user.avatar')">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex-1">
|
||||
<ImageSelector
|
||||
v-model="profileForm.avatar"
|
||||
:enable-crop="true"
|
||||
crop-shape="circle"
|
||||
:max-size="2"
|
||||
:size="100"
|
||||
:placeholder="$t('user.selectAvatar')"
|
||||
@update:model-value="handleAvatarChange"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $t('user.avatarHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElDivider />
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<ElFormItem :label="$t('user.userName')" prop="name">
|
||||
<ElInput
|
||||
v-model="profileForm.name"
|
||||
:placeholder="$t('user.userName')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.email')" prop="email">
|
||||
<ElInput
|
||||
v-model="profileForm.email"
|
||||
type="email"
|
||||
:placeholder="$t('user.email')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.mobile')" prop="mobile">
|
||||
<ElInput
|
||||
v-model="profileForm.mobile"
|
||||
:placeholder="$t('user.mobile')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.gender')">
|
||||
<ElRadioGroup v-model="profileForm.gender">
|
||||
<ElRadio
|
||||
v-for="option in genderOptions"
|
||||
:key="option.value"
|
||||
:label="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.birthday')">
|
||||
<ElDatePicker
|
||||
v-model="profileForm.birthday"
|
||||
type="date"
|
||||
:placeholder="$t('user.selectBirthday')"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.city')">
|
||||
<ElInput
|
||||
v-model="profileForm.city"
|
||||
:placeholder="$t('user.city')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem :label="$t('user.address')">
|
||||
<ElInput
|
||||
v-model="profileForm.address"
|
||||
:placeholder="$t('user.address')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('user.bio')">
|
||||
<ElInput
|
||||
v-model="profileForm.bio"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('user.bioPlaceholder')"
|
||||
:maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" :loading="loading" @click="handleSaveProfile">
|
||||
{{ $t('common.save') }}
|
||||
</ElButton>
|
||||
<ElButton @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</template>
|
||||
Reference in New Issue
Block a user