feat(setting): 移动端设置页改版,顶部个人资料卡、图标与右箭头、右侧显示当前值、按语义分组,退出登录改为底部按钮

- 引入 ionicons 8 新版图标,新增全局 IonIcon 组件(按需引入 SVG 内联渲染,不走 CDN)
- 桌面端设置菜单保持不变
- 同步 ai-kb 设置入口与个人设置路径
This commit is contained in:
kuaifan 2026-10-28 09:49:06 +00:00
parent 1fb1062056
commit 58a0180caa
9 changed files with 232 additions and 21 deletions

View File

@ -43,6 +43,7 @@
"html-to-md": "^0.8.8",
"inquirer": "^8.2.0",
"internal-ip": "^6.2.0",
"ionicons": "^8.1.0",
"jquery": "^3.6.4",
"jspdf": "^2.5.1",
"less": "^4.1.2",

View File

@ -20,7 +20,7 @@ negative:
- 邮箱在「个人设置」中只读,修改需走「修改邮箱」子页
- 部门信息只读,不可自助修改,需联系系统管理员调整
- LDAP 用户的昵称 / 头像 / 电话改动会同步回 LDAP,但 LDAP 拒收时本地也不会保存
last_verified: v1.7.90
last_verified: v1.10.12
---
# 修改个人资料
@ -28,7 +28,7 @@ last_verified: v1.7.90
## 入口
- 桌面端:右上角头像 →「设置」→「个人设置」
- 移动端:底部「我的」→「设置」→「个人设置」
- 移动端:底部「我的」→「设置」→ 点顶部个人资料卡(头像、昵称)
- 后端接口:`POST api/users/editdata`
## 可改字段与约束

View File

@ -18,7 +18,7 @@ prerequisites: []
negative:
- 个人设置没有移动端 Tabbar 一级入口,需先进「我的」再进设置
- 未登录用户没有「个人设置」入口
last_verified: v1.9.66
last_verified: v1.10.12
---
# 个人设置入口
@ -32,7 +32,7 @@ last_verified: v1.9.66
- 移动端:底部 Tabbar「我的」→「设置」
- 路由:`/manage/setting/personal`(默认子页),其他子页为 `/manage/setting/<path>`
## 子页清单(按菜单顺序)
## 子页清单(桌面端菜单顺序)
- 个人设置(资料)→ [[user-settings.profile.howto]]
- 密码设置 → [[user-settings.password.howto]]
@ -44,6 +44,14 @@ last_verified: v1.9.66
- 系统更新(仅系统管理员可见,有系统新版本时显示新版本号)→ [[system-update.online-update.howto]]
- 更新日志(桌面端有新版本时显示为「更新客户端」,点击打开客户端更新弹窗) / 版本(新版移动端 App 右侧有「检查更新」,见 [[mobile-client.update.howto]]) / 登录设备 / 清除缓存 / 退出登录
## 移动端(竖屏)菜单布局
移动端设置页与桌面端子页相同,但排版不同:
- 顶部为个人资料卡(头像、昵称、邮箱),点击进入「个人设置」
- 其余项带图标与右箭头,按组排列:密码设置 / 修改邮箱 / 登录设备(右侧显示设备数)→ 语言设置 / 主题设置(右侧显示当前语言、主题)/ 键盘设置 → 系统设置 / License Key / 系统更新(仅管理员)→ 版本 / 更新日志 / 隐私政策 → 清除缓存 / 删除帐号(红色)
- 「退出登录」为列表最底部单独的红色按钮
## 权限要求
- 所有登录用户可见

View File

@ -43,6 +43,7 @@ import TagInput from './components/TagInput.vue'
import TableAction from './components/TableAction.vue'
import QuickEdit from './components/QuickEdit.vue'
import UserAvatar from './components/UserAvatar'
import IonIcon from './components/IonIcon.vue'
import Imgs from './components/Replace/Imgs'
import ImgView from './components/ImgView.vue'
import Scrollbar from './components/Scrollbar'
@ -54,6 +55,7 @@ Vue.component('TagInput', TagInput)
Vue.component('TableAction', TableAction);
Vue.component('QuickEdit', QuickEdit);
Vue.component('UserAvatar', UserAvatar);
Vue.component('IonIcon', IonIcon);
Vue.component('Imgs', Imgs);
Vue.component('ImgView', ImgView);
Vue.component('Scrollbar', Scrollbar);

View File

@ -0,0 +1,31 @@
<script>
/**
* Ionicons(新版)SVG 图标
*
* 用法:
* import {lockClosedOutline} from "ionicons/icons";
* <IonIcon :icon="lockClosedOutline"/>
*
* 按需引入 ionicons/icons 导出的 data URI,内联渲染为 SVG(不使用 <ion-icon> 组件,避免运行时从 CDN 加载)
* 尺寸跟随 font-size,颜色跟随 color
*/
export default {
name: 'IonIcon',
functional: true,
props: {
icon: {
type: String,
required: true
},
},
render(h, {props, data}) {
const {icon} = props
return h('i', Object.assign({}, data, {
staticClass: ['ion-icon', data.staticClass].filter(Boolean).join(' '),
domProps: {
innerHTML: icon.slice(icon.indexOf(',') + 1)
},
}))
}
}
</script>

View File

@ -11,12 +11,21 @@
<div class="setting-box">
<div class="setting-menu">
<MobileNavTitle :title="$L('设置')"/>
<div v-if="windowPortrait" class="setting-profile" @click="toggleRoute('personal')">
<UserAvatar :userid="userId" :size="52" :show-state-dot="false"/>
<div class="setting-profile-info">
<div class="setting-profile-name">{{userInfo.nickname}}</div>
<div class="setting-profile-email">{{userInfo.email}}</div>
</div>
<IonIcon class="setting-menu-arrow" :icon="chevronForward"/>
</div>
<ul>
<li
v-for="(item, key) in menu"
v-for="(item, key) in (windowPortrait ? mobileMenu : menu)"
:key="key"
:class="classNameRoute(item.path, item.divided)"
:class="classNameRoute(item.path, item.divided, item.danger)"
@click="toggleRoute(item.path)">
<IonIcon v-if="item.icon" class="setting-menu-icon" :icon="item.icon"/>
<template v-if="item.path === 'device'">
<AutoTip>{{$L(item.name)}}</AutoTip>
<span v-if="deviceCount > 0" class="op-8">{{deviceCount}}</span>
@ -30,12 +39,20 @@
<Badge v-if="!!systemNewVersion" :text="systemNewVersion"/>
</template>
<template v-else-if="item.path === 'version-show'">
<AutoTip>{{$L(item.name)}}: {{clientVersion}}</AutoTip>
<AutoTip v-if="windowPortrait">{{$L(item.name)}}</AutoTip>
<AutoTip v-else>{{$L(item.name)}}: {{clientVersion}}</AutoTip>
<span v-if="windowPortrait" class="setting-menu-value">{{clientVersion}}</span>
<span v-if="canCheckUpdate" class="check-update" @click.stop="onCheckUpdate">{{$L('检查更新')}}</span>
</template>
<template v-else-if="item.value">
<AutoTip>{{$L(item.name)}}</AutoTip>
<span class="setting-menu-value">{{item.value}}</span>
</template>
<span v-else>{{$L(item.name)}}</span>
<IonIcon v-if="item.arrow" class="setting-menu-arrow" :icon="chevronForward"/>
</li>
</ul>
<div v-if="windowPortrait" class="setting-logout" @click="toggleRoute('logout')">{{$L('退出登录')}}</div>
</div>
<transition :name="$isEEUIApp ? 'mobile-dialog' : 'none'">
<div v-if="showContent" class="setting-content">
@ -55,6 +72,24 @@ import {mapState} from "vuex";
import axios from "axios";
import MobileNavTitle from "../../../components/Mobile/NavTitle.vue";
import emitter from "../../../store/events";
import {languageList, languageName} from "../../../i18n";
import {
chevronForward,
contrastOutline,
documentTextOutline,
informationCircleOutline,
keyOutline,
keypadOutline,
languageOutline,
lockClosedOutline,
mailOutline,
personRemoveOutline,
phonePortraitOutline,
settingsOutline,
shieldCheckmarkOutline,
syncOutline,
trashOutline,
} from "ionicons/icons";
export default {
components: {MobileNavTitle},
@ -64,6 +99,7 @@ export default {
serverVersion: null,
clientVersion: window.systemInfo.version,
canCheckUpdate: $A.eeuiAppIsExpo(),
chevronForward,
}
},
@ -78,7 +114,7 @@ export default {
},
computed: {
...mapState(['userInfo', 'userIsAdmin', 'clientNewVersion', 'systemNewVersion', 'systemConfig']),
...mapState(['userId', 'userInfo', 'userIsAdmin', 'clientNewVersion', 'systemNewVersion', 'systemConfig', 'themeConf', 'themeList']),
showContent() {
return this.$route.path.match(/^\/manage\/setting\/[\w-]+$/)
@ -123,6 +159,49 @@ export default {
return menu;
},
/**
* 移动端菜单:个人设置移至顶部资料卡、退出登录改为底部按钮,其余按语义重新分组
*/
mobileMenu() {
const theme = this.themeList.find(item => item.value === this.themeConf)
const groups = [
[
{path: 'password', name: '密码设置', icon: lockClosedOutline},
{path: 'email', name: '修改邮箱', icon: mailOutline},
{path: 'device', name: '登录设备', icon: phonePortraitOutline},
],
[
{path: 'language', name: '语言设置', icon: languageOutline, value: languageList[languageName]},
{path: 'theme', name: '主题设置', icon: contrastOutline, value: theme ? this.$L(theme.name) : ''},
(this.$Electron || this.$isEEUIApp) && {path: 'keyboard', name: '键盘设置', icon: keypadOutline},
],
this.userIsAdmin && [
{path: 'system', name: '系统设置', icon: settingsOutline},
{path: 'license', name: 'License Key', icon: keyOutline},
{path: 'system-update', name: '系统更新', icon: syncOutline},
],
[
{path: 'version-show', name: '版本', icon: informationCircleOutline},
{path: 'version', name: this.clientNewVersion ? '更新客户端' : '更新日志', title: '更新日志', icon: documentTextOutline},
$A.isDooServer() && this.$isEEUIApp && {path: 'privacy', name: '隐私政策', icon: shieldCheckmarkOutline},
],
[
{path: 'clearCache', name: '清除缓存', icon: trashOutline},
$A.isDooServer() && this.$isEEUIApp && {path: 'delete', name: '删除帐号', icon: personRemoveOutline, danger: true},
],
]
const menu = []
groups.filter(Boolean).forEach(group => {
group.filter(Boolean).forEach((item, index) => {
menu.push(Object.assign(item, {
divided: index === 0,
arrow: item.path !== 'version-show',
}))
})
})
return menu
},
titleNameRoute() {
const {routeName, menu} = this;
let name = '';
@ -219,11 +298,12 @@ export default {
}
},
classNameRoute(path, divided) {
classNameRoute(path, divided, danger) {
return {
"flex": true,
"active": this.windowLandscape && this.routeName === `manage-setting-${path}`,
"divided": !!divided
"divided": !!divided,
"danger": !!danger
};
},

View File

@ -4,6 +4,7 @@
@import "float-spinner";
@import "gantt-view";
@import "img-update";
@import "ion-icon";
@import "loading";
@import "mobile";
@import "scroller-y";

View File

@ -0,0 +1,25 @@
// 与 ionicons 官方 <ion-icon> 样式保持一致(见 components/IonIcon.vue)
.ion-icon {
display: inline-block;
width: 1em;
height: 1em;
font-style: normal;
line-height: 1;
vertical-align: middle;
fill: currentColor;
> svg {
display: block;
width: 100%;
height: 100%;
stroke: currentColor;
}
.ionicon-fill-none {
fill: none;
}
.ionicon-stroke-width {
stroke-width: 32px;
}
}

View File

@ -553,40 +553,103 @@ body.window-portrait {
width: 100%;
height: 100%;
z-index: 1;
.setting-profile {
display: flex;
align-items: center;
padding: 18px 20px;
background-color: #ffffff;
.setting-profile-info {
flex: 1;
min-width: 0;
margin-left: 14px;
.setting-profile-name {
font-size: 17px;
font-weight: 500;
line-height: 24px;
color: $primary-title-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setting-profile-email {
margin-top: 2px;
font-size: 13px;
line-height: 18px;
color: $primary-desc-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.setting-menu-arrow {
flex-shrink: 0;
margin-left: auto;
padding-left: 6px;
font-size: 18px;
color: #c5c8ce;
}
> ul {
// 沉浸底部安全区(见 App.vue immersiveNavigation)
padding: 0 0 calc(20px + var(--navigation-bar-height));
padding: 0;
> li {
position: relative;
padding: 0 32px;
line-height: 48px;
padding: 0 20px;
line-height: 52px;
margin: 0;
font-size: 15px;
color: $primary-title-color;
background-color: #ffffff;
&:after {
content: "";
position: absolute;
top: 0;
left: 32px;
left: 56px;
right: 0;
z-index: 1;
height: 1px;
transform: scaleY(0.5);
background-color: #F4F4F5;
}
&:first-child {
&:after {
display: none;
}
}
&.divided {
margin-top: 20px;
margin-top: 10px;
padding-top: 0;
&:before,
&:after {
display: none;
}
}
&.danger {
color: #ed4014;
.setting-menu-icon {
color: #ed4014;
}
}
.setting-menu-icon {
flex-shrink: 0;
width: 22px;
margin-right: 14px;
font-size: 22px;
color: $primary-text-color;
}
.setting-menu-value,
.op-8 {
flex-shrink: 0;
margin-left: 12px;
color: $primary-desc-color;
opacity: 1;
}
}
}
.setting-logout {
margin-top: 10px;
// 沉浸底部安全区(见 App.vue immersiveNavigation)
margin-bottom: calc(20px + var(--navigation-bar-height));
line-height: 52px;
font-size: 15px;
text-align: center;
color: #ed4014;
background-color: #ffffff;
}
}
.setting-content {
position: absolute;