Remove account settings remnants

This commit is contained in:
2026-06-09 16:26:45 +08:00
parent 7da55f0935
commit c5aa7cd00e
24 changed files with 2 additions and 1761 deletions
+1 -1
View File
@@ -6806,7 +6806,7 @@
"badgeVariants": null, "badgeVariants": null,
"id": "ys46Xxzw3B4lMj83oaUUx", "id": "ys46Xxzw3B4lMj83oaUUx",
"sort": 0, "sort": 0,
"is_deleted": false, "is_deleted": true,
"sys_create_datetime": "2026-01-02T04:03:16.317341", "sys_create_datetime": "2026-01-02T04:03:16.317341",
"sys_update_datetime": "2026-01-02T04:04:55.264446", "sys_update_datetime": "2026-01-02T04:04:55.264446",
"sys_creator_id": null, "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}`);
}
-77
View File
@@ -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
View File
@@ -1,5 +1,4 @@
export * from './announcement'; export * from './announcement';
export * from './api-token';
export * from './application'; export * from './application';
export * from './auth'; export * from './auth';
export * from './data-source'; export * from './data-source';
-1
View File
@@ -23,7 +23,6 @@ const modules = import.meta.glob([
'./langs/*/about.json', './langs/*/about.json',
'./langs/*/ai-platform.json', './langs/*/ai-platform.json',
'./langs/*/announcement.json', './langs/*/announcement.json',
'./langs/*/apiToken.json',
'./langs/*/authentication.json', './langs/*/authentication.json',
'./langs/*/common.json', './langs/*/common.json',
'./langs/*/menu.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", "roleManagement": "Role Permission",
"userCenter": "User Center", "userCenter": "User Center",
"loginLog": "Login Log", "loginLog": "Login Log",
"accountSettings": "Account Settings",
"databaseManagement": "Database Management", "databaseManagement": "Database Management",
"dbManagement": "DB Management", "dbManagement": "DB Management",
"databaseConnection": "Database Connections", "databaseConnection": "Database Connections",
@@ -51,7 +51,6 @@
"resetPasswordConfirm": "Are you sure to reset password for user \"{0}\"?\nThe password will be reset to: admin123", "resetPasswordConfirm": "Are you sure to reset password for user \"{0}\"?\nThe password will be reset to: admin123",
"resetPasswordSuccess": "Password reset successfully for user \"{0}\"", "resetPasswordSuccess": "Password reset successfully for user \"{0}\"",
"resetPasswordError": "Failed to reset password", "resetPasswordError": "Failed to reset password",
"accountSettings": "Account Settings",
"basicInfo": "Basic Information", "basicInfo": "Basic Information",
"changePassword": "Change Password", "changePassword": "Change Password",
"oldPassword": "Current 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": "角色权限", "roleManagement": "角色权限",
"userCenter": "用户中心", "userCenter": "用户中心",
"loginLog": "登录日志", "loginLog": "登录日志",
"accountSettings": "账号设置",
"databaseManagement": "数据库管理", "databaseManagement": "数据库管理",
"dbManagement": "DB管理", "dbManagement": "DB管理",
"databaseConnection": "数据库连接", "databaseConnection": "数据库连接",
@@ -51,7 +51,6 @@
"resetPasswordConfirm": "确定要重置用户{0}的密码吗?\n密码将重置为:admin123", "resetPasswordConfirm": "确定要重置用户{0}的密码吗?\n密码将重置为:admin123",
"resetPasswordSuccess": "用户{0}的密码重置成功", "resetPasswordSuccess": "用户{0}的密码重置成功",
"resetPasswordError": "重置密码失败", "resetPasswordError": "重置密码失败",
"accountSettings": "账号设置",
"basicInfo": "基本信息", "basicInfo": "基本信息",
"changePassword": "修改密码", "changePassword": "修改密码",
"oldPassword": "当前密码", "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": "角色權限", "roleManagement": "角色權限",
"userCenter": "用戶中心", "userCenter": "用戶中心",
"loginLog": "登錄日誌", "loginLog": "登錄日誌",
"accountSettings": "賬號設置",
"databaseManagement": "數據庫管理", "databaseManagement": "數據庫管理",
"dbManagement": "DB管理", "dbManagement": "DB管理",
"databaseConnection": "資料庫連接", "databaseConnection": "資料庫連接",
@@ -51,7 +51,6 @@
"resetPasswordConfirm": "確定要重置用戶 \"{0}\" 的密碼嗎?\n密碼將重置為:admin123", "resetPasswordConfirm": "確定要重置用戶 \"{0}\" 的密碼嗎?\n密碼將重置為:admin123",
"resetPasswordSuccess": "用戶 \"{0}\" 的密碼重置成功", "resetPasswordSuccess": "用戶 \"{0}\" 的密碼重置成功",
"resetPasswordError": "重置密碼失敗", "resetPasswordError": "重置密碼失敗",
"accountSettings": "賬號設置",
"basicInfo": "基本信息", "basicInfo": "基本信息",
"changePassword": "修改密碼", "changePassword": "修改密碼",
"oldPassword": "當前密碼", "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>