Compare commits

...
7 Commits
Author SHA1 Message Date
chenxing e2c937f424 feat(avatar): 默认头像轮前端集成——设置页两级样式 + 登录写学校默认键 + 顶栏三态头像
build / build (push) Successful in 5m35s
- BaseApi:schAvatarStyle/saveSchAvatarStyle/clearSchAvatarStyle 三端点(后端 SchSettingsController 配套,本仓 service 仓随轮提交)
- App.vue:登录后 loadSchAvatarDefault()(与 loadSchThemeDefault 同构:失败/404/空→不写键不报错不阻塞),写 localStorage[yh-avatar-sch-default]
- Settings.vue:新增 avatar pane(我的头像样式人人可见 + 本校默认管理员可配,watch 进 pane 拉取)
- AppHeader/UserInfoModal:头像展示位换 UserAvatar 三态组件
- ThemePreview:预览页头像形态联动
运行时联调与部署验证待人工(后端端点须随 service 发版)
2026-10-06 20:20:44 +08:00
chenxing 4d9da5e3a6 feat(appicon): AppIcon 资产库接入(应用网格矢量图标渲染,YschAppIcons 10-04 轮 P3)
- AppIcon.vue:矢量图标渲染组件(iconKey+iconColor → mask data-uri,无键无 URL 回退关键词映射占位)
- constants/appIconRules.ts:应用名关键词→图标键兜底映射(D4,两端各持一份,与 MobilePortal 关键词列一致;仅 icon_key 未配置且无 URL 时生效,有 URL 恒定位图零回归)
- AppList/SearchPanel/index:应用网格与搜索结果图标位换 AppIcon
- appicon.mask.css:资产库 mask 样式;yunhai-logo.svg:站点 Logo 矢量件
- main.scss/_app_container.scss:网格图标容器样式配套
依据:根目录《AppIcon资产库落地-执行报告-20261004.md》P3 UserHome(PC)接入
2026-10-06 20:20:32 +08:00
chenxing 93249cf5f5 chore(sync): YschTheme sync 件刷新(theme.ts 20261006-2013-75c1d10 + avatar 两件首次入库)+ view-ui-plus ^1.3.24 对齐
theme.ts 仅版本戳行变更(20260930-1248 → 20261006-2013);avatar.ts/UserAvatar.vue 为 YschTheme 默认头像轮 sync 下发公共源;package.json view-ui-plus ^1.3.19 → ^1.3.24 对齐通道生成基线(YschTheme iview-version 版本锁基准)
2026-10-06 20:20:30 +08:00
chenxing 1d34e5745d feat(settings): 个人信息手机号/邮箱开放自助修改——保存按钮启用走 /api/user/UpdateContact(前端校验与后端同口径:手机号 1[3-9] 段位/邮箱格式/长度),成功以接口返回的最新 BoaUser 合并 store.userInfo 即时回显(规避 CAS principal 缓存旧值);BaseApi 新增 updateContact(任务B·前端) 2026-10-02 08:56:46 +08:00
chenxing f52ce4136e feat(settings): 我的壁纸 pane(mywallpaper,主题分组,所有人可见)——能力复制自 Header 设置壁纸模态框(模态框保留不动):系统/校区/我的上传三组网格点选即保存(checkWallpaper)、当前值回显 store 用户设置、上传走 /api/wallpaperUpload、个人壁纸可删;零新接口(任务A) 2026-10-02 08:45:47 +08:00
chenxing 7e239a0c73 style(settings): 边栏设置 pane 列宽与样式微调——未设宽列补 width/minWidth(名称120/图标70/地址220/打开方式100),地址列 tooltip 超长省略,操作列固定右侧,工具栏加分隔线收紧间距;Header「边栏设置」菜单改跳 /settings?pane=sidebar 并移除 test 残留按钮与旧 MenuBarModal 挂载(组件文件保留)(任务D) 2026-10-02 08:44:06 +08:00
chenxing 41f4dade91 fix(settings): 个人信息「所属部门」显示组路径——BoaUser.dept 是对象直显成 [object Object],改为取 groups 链学校组(type=2)→挂载组区段反向 join('/')(如「云海小学/校长室」),无学校组回退挂载组名、链空回退 dept.name、兜底 '-'(任务C) 2026-10-02 08:42:16 +08:00
20 changed files with 1031 additions and 70 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
"pinia": "^2.1.4", "pinia": "^2.1.4",
"sass": "^1.64.1", "sass": "^1.64.1",
"v-wave": "^3.0.2", "v-wave": "^3.0.2",
"view-ui-plus": "^1.3.19", "view-ui-plus": "^1.3.24",
"vite-plugin-importer": "^0.2.4", "vite-plugin-importer": "^0.2.4",
"vue": "^3.4.19", "vue": "^3.4.19",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
+16
View File
@@ -62,6 +62,7 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
const [r1, r2] = await Promise.all([p1, p2]); const [r1, r2] = await Promise.all([p1, p2]);
store.$state.userInfo = userinfo; store.$state.userInfo = userinfo;
loadSchThemeDefault(); loadSchThemeDefault();
loadSchAvatarDefault();
if (r1.length == 0) { if (r1.length == 0) {
store.$state.userRoleList = [ store.$state.userRoleList = [
{ {
@@ -103,6 +104,21 @@ function loadSchThemeDefault() {
.catch(() => undefined); .catch(() => undefined);
} }
/** 拉学校默认头像样式写键(默认头像轮 2026-09-30;与 loadSchThemeDefault 同构:失败/404/空 → 不写、不报错、不阻塞登录流程) */
function loadSchAvatarDefault() {
BaseApi.getSchAvatarStyle()
.then((res: any) => {
if (res && res.avatarStyle) {
try {
localStorage.setItem("yh-avatar-sch-default", res.avatarStyle);
} catch (e) {
/* 隐私模式写失败静默 */
}
}
})
.catch(() => undefined);
}
/**切换角色 */ /**切换角色 */
async function switchRole() { async function switchRole() {
await loadUserMenuBarList(); await loadUserMenuBarList();
+32
View File
@@ -14,6 +14,15 @@ class Base extends Abstract {
return this.getRequest({ url, isMsg: false }); return this.getRequest({ url, isMsg: false });
} }
/**
* 修改本人联系方式(手机号/邮箱,至少一项非空;后端经 UserBase 开放接口落库)。
* 成功返回更新后的最新用户信息(BoaUser,含新 phone/email),供调用方刷新 store 规避 CAS principal 旧值。
*/
updateContact(phone: string, email: string) {
const url = path + "/user/UpdateContact";
return this.postRequest<any>({ url, data: { phone, email } });
}
/** /**
*获取用户全部角色 *获取用户全部角色
* @memberof Basic * @memberof Basic
@@ -204,6 +213,29 @@ class Base extends Abstract {
return this.postRequest({ url }); return this.postRequest({ url });
} }
// ===== 学校默认头像样式(默认头像轮 2026-09-30;后端契约见 SchSettingsController#schAvatarStyle)=====
/**
* 读本校默认头像样式(已登录可读):{avatarStyle: "text"|"silhouette"|null}——
* null 表示未配置(前端"不写键、不报错"),登录后调一次写 localStorage["yh-avatar-sch-default"]
*/
getSchAvatarStyle() {
const url = path + "/schAvatarStyle";
return this.getRequest<{ avatarStyle: string | null; schId: number }>({ url });
}
/** 保存本校默认头像样式(仅学校管理员):avatarStyle = "text" | "silhouette" */
saveSchAvatarStyle(avatarStyle: string) {
const url = path + "/saveSchAvatarStyle";
return this.postRequest({ url, params: { avatarStyle } });
}
/** 清空本校默认头像样式(仅学校管理员;置空后读侧返回 null) */
clearSchAvatarStyle() {
const url = path + "/clearSchAvatarStyle";
return this.postRequest({ url });
}
savUserRole(info: any) { savUserRole(info: any) {
const url = path + "/user/savUserRole"; const url = path + "/user/savUserRole";
return this.postRequest({ url, data: info as any }); return this.postRequest({ url, data: info as any });
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

+33
View File
@@ -0,0 +1,33 @@
/* appicon 图标(CSS mask + currentColor,data-uri 自包含),共 2 个。
用法 <i class="appicon appicon-<名>"></i>;命名空间隔离:类名恒为 .appicon*,无 .icon-*。 */
.appicon {
font-family: inherit;
font-style: normal;
font-size: 16px;
line-height: 1;
display: inline-block;
vertical-align: middle;
}
.appicon::before {
content: "" !important;
display: block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.appicon-tongzhi::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z' %3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z' %3E%3C/path%3E%3C/svg%3E");
}
.appicon-wenjuan::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z' %3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z' %3E%3C/path%3E%3C/svg%3E");
}
@@ -56,3 +56,53 @@
// border-radius: 2px; // border-radius: 2px;
// background: rgba(255, 255, 255, 0.25); // background: rgba(255, 255, 255, 0.25);
} }
/* ===================== AppIcon 矢量图标三档 scene(方案 §4 渲染契约) =====================
* 图标资源只管形状,容器管底色与图标色;底色为端侧派生 token(不落库)。
* 形状类(.appicon-<名> 的 mask data-uri)来自全局 appicon.mask.css;
* AppIcon.vue 组件按 scene/tint 组合类名与内联覆盖(tint=application_info.icon_color 人工配置色)。
* 尺寸对齐上方 img 位图(60px / 18% 圆角),保证矢量与位图在网格里视觉一致。
*/
.appicon {
display: inline-block;
line-height: 1;
vertical-align: middle;
box-sizing: border-box;
}
.appicon-tile {
// PC 应用卡:方块底 + 白图标(对齐位图 60px/18%)
--appicon-bg: var(--brand, #366ef4); // 端侧主题 token(通道 CSS 不可达时兜底蓝)
width: 60px;
height: 60px;
border-radius: 18%;
background: var(--appicon-bg);
color: #fff;
font-size: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.appicon-circle {
// App 首页快捷:正圆底 + 白图标
--appicon-bg: var(--brand, #366ef4);
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--appicon-bg);
color: #fff;
font-size: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.appicon-plain {
// 无底线性:图标色继承(分组色 / tint)
width: auto;
height: auto;
background: transparent;
color: var(--appicon-ink, currentColor);
font-size: 26px;
}
+6
View File
@@ -6,6 +6,12 @@
@use "utils/utils"; @use "utils/utils";
@use "utils/bsch_title"; @use "utils/bsch_title";
// AppIcon 资产库(CSS mask 版,data-uri 自包含):
// 拷贝自 D:\SoftProject\yunhai\YschAppIcons\appicon.mask.css(资产库 build_mask_css.py 产物)。
// 同步方式:资产库重跑构建后整文件覆盖本处,避免 P1 静态应用部署顺序依赖与跨域问题。
// 注意:必须 @use 在 componets/* 之前——scene 档(.appicon-tile 等)需覆盖本文件的 .appicon 基类字号。
@use "appicon.mask.css";
@use "componets/status"; @use "componets/status";
@use "componets/small_table"; @use "componets/small_table";
@use "componets/my_table"; @use "componets/my_table";
+2 -1
View File
@@ -8,7 +8,7 @@
<h4 class="fl align-middle">{{ appName }}</h4> <h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div> <div class="school-bg"></div>
<div class="header-aside fr"> <div class="header-aside fr">
<img :src="userInfo.profilePicture" @click="showUserInfo" class="fl align-middle" /> <UserAvatar :src="userInfo.profilePicture" :name="userInfo.name" :size="28" placement="brand" class="fl align-middle" @click="showUserInfo" />
<a class="name fl align-middle text-ellipsis" @click="showUserInfo" href="javascript:void(0);"> <a class="name fl align-middle text-ellipsis" @click="showUserInfo" href="javascript:void(0);">
Hi Hi
<span class="ml-4">{{ userInfo.name }}</span> <span class="ml-4">{{ userInfo.name }}</span>
@@ -27,6 +27,7 @@
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { computed } from "vue"; import { computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import UserAvatar from "@/components/UserAvatar.vue";
import mittBus from "@/utils/mitt"; import mittBus from "@/utils/mitt";
import { StateEnum } from "@/types"; import { StateEnum } from "@/types";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
+36
View File
@@ -0,0 +1,36 @@
<template>
<i class="appicon" :class="classes" :style="styles" aria-hidden="true"></i>
</template>
<script lang="ts" setup>
/**
* 矢量应用图标(AppIcon 资产库,CSS mask 版)。
* - name:资产库图标键(appicon-<名>,形状来自全局 appicon.mask.css,data-uri 自包含)
* - scene:tile 方块底 / circle 正圆底 / plain 无底线性(三档端侧 token,见 _app_container.scss)
* - tint:人工配置色(application_info.icon_color)——tile/circle 覆盖底色、plain 覆盖图标色;空=场景默认
* 渲染契约(方案 §4):图标资源只管形状,容器管底色与图标色。
*/
import { computed } from "vue";
const props = withDefaults(defineProps<{
name?: string;
scene?: "tile" | "circle" | "plain";
tint?: string;
}>(), {
name: "",
scene: "tile",
tint: "",
});
const classes = computed(() => [
`appicon-${props.scene}`,
...(props.name ? [`appicon-${props.name}`] : []),
]);
const styles = computed<Record<string, string> | undefined>(() => {
if (!props.tint) return undefined;
return props.scene === "plain"
? { color: props.tint }
: { backgroundColor: props.tint };
});
</script>
+6 -9
View File
@@ -2,9 +2,6 @@
<header :class="headerClass"> <header :class="headerClass">
<img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" /> <img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" />
<ul class="operate-container fix"> <ul class="operate-container fix">
<!-- <li>
<Button @click="settingsMenu">test</Button>
</li> -->
<li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }"> <li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }">
<div class="icon-box" v-wave @click="toggleWidgetShow"> <div class="icon-box" v-wave @click="toggleWidgetShow">
<SvgIcon name="header-4" color="#333333" /> <SvgIcon name="header-4" color="#333333" />
@@ -103,7 +100,9 @@
<img :src="requireImg('userinfo/icon3.png')" alt="" /> <img :src="requireImg('userinfo/icon3.png')" alt="" />
<span>设置LOGO</span> <span>设置LOGO</span>
</li> </li>
<li @click="settingsMenu" v-if="!!store.getIsAdmin"> <!-- 边栏设置已迁设置中心(/settings?pane=sidebar,2026-09-30 二轮迁入真实管理),
此处仅跳转不再开旧 MenuBarModal(组件保留未挂载) -->
<li @click="linkSidebarSetting" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon6.png')" alt="" /> <img :src="requireImg('userinfo/icon6.png')" alt="" />
<span>边栏设置</span> <span>边栏设置</span>
</li> </li>
@@ -225,7 +224,6 @@
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 --> <!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
<MsgApprovalModal ref="refMsgApproval" /> <MsgApprovalModal ref="refMsgApproval" />
<MenuBarModal ref="refMenuBarModal" />
<StatsBarModal ref="refStatsBarModal" /> <StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" /> <TeacherCopilotModal ref="refTeacherCopilotModal" />
</template> </template>
@@ -240,7 +238,6 @@ import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg"; import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import MenuBarModal from "@/components/MenuBarModal.vue";
import StatsBarModal from "@/components/StatsBarModal.vue"; import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue"; import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
@@ -264,7 +261,6 @@ const baseUrl = computed(() => $conf.fileHost);
const { unreadCount } = storeToRefs(store); const { unreadCount } = storeToRefs(store);
const refSearchInput = ref<any>(); const refSearchInput = ref<any>();
const refMenuBarModal = ref<any>();
const refStatsBarModal = ref<any>(); const refStatsBarModal = ref<any>();
const refTeacherCopilotModal = ref<any>(); const refTeacherCopilotModal = ref<any>();
@@ -473,9 +469,10 @@ async function settingsWallpaper() {
isWallpaperShow.value = true; isWallpaperShow.value = true;
} }
async function settingsMenu() { /** 边栏设置入口改跳设置中心 pane(原 settingsMenu 开旧 MenuBarModal 的能力已于 2026-09-30 迁入 Settings.vue sidebar pane) */
function linkSidebarSetting() {
isUserInfoShow.value = false; isUserInfoShow.value = false;
refMenuBarModal.value.show(); router.push("/settings?pane=sidebar");
} }
async function settingsStats() { async function settingsStats() {
+15 -1
View File
@@ -13,7 +13,9 @@
<vuescroll> <vuescroll>
<div class="app-search-list"> <div class="app-search-list">
<div v-for="(item, index) in appList" class="app-container"> <div v-for="(item, index) in appList" class="app-container">
<img :src="$utils.appIconPath(item.icon)" /> <AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:tint="item.iconColor" />
<img v-else :src="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div> <div class="app-name">{{ item.name }}</div>
</div> </div>
</div> </div>
@@ -26,10 +28,22 @@
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { ref, inject, computed, onMounted, watch } from "vue"; import { ref, inject, computed, onMounted, watch } from "vue";
import vuescroll from "vuescroll"; import vuescroll from "vuescroll";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $utils } = useCurrentInstance(); const { $utils } = useCurrentInstance();
/**
* 矢量渲染判定(方案 §5.4 回退链,与 AppList.vue / index.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
const refInput = ref(); const refInput = ref();
const searchName = ref(); const searchName = ref();
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle());
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+2 -2
View File
@@ -15,7 +15,7 @@
:headers="uploadHeaders" :headers="uploadHeaders"
> >
<div class="avatar-wrap" title="点击更换头像"> <div class="avatar-wrap" title="点击更换头像">
<img :src="requireImg('user-avatar-default.png')" v-real-img="store.userInfo.profilePicture" alt="" /> <UserAvatar :src="store.userInfo.profilePicture" :name="store.userInfo.name" :size="120" placement="light" />
<div class="avatar-mask">更换头像</div> <div class="avatar-mask">更换头像</div>
</div> </div>
</Upload> </Upload>
@@ -67,8 +67,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, onUnmounted, reactive } from "vue"; import { ref, onMounted, onUnmounted, reactive } from "vue";
import { requireImg } from "@/utils/hook/useImg";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import UserAvatar from "@/components/UserAvatar.vue";
import mittBus from "@/utils/mitt"; import mittBus from "@/utils/mitt";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
import Utils from "@/utils"; import Utils from "@/utils";
+43
View File
@@ -0,0 +1,43 @@
/**
* 应用名称关键词 → AppIcon 资产库图标键映射(D4:两端各持一份,关键词列与 MobilePortal 完全一致)。
*
* 定位(方案 §5.4 / E4):只做兜底、不做主路径——仅在「icon_key 未配置 且 无 icon URL」时生效
* (替代默认占位图);有 URL 的存量应用恒定位图渲染,零回归。
*
* 值列语义:appicon 图标键(如 tongzhi);空串 = 该关键词暂无对应图标(dormant,资产库补图后填值激活)。
* 一致性断言:YschAppIcons/verify_map.mjs 深比较两端关键词列(含顺序);值列两端允许不同
* (MobilePortal 用 wd-icon 名,本表用 appicon 键)。
*/
export const APP_ICON_RULES: [string, string][] = [
['请假', ''],
['课程', ''],
['课表', ''],
['通知', ''],
['公告', ''],
['报修', ''],
['文印', ''],
['打印', ''],
['公文', ''],
['作业', ''],
['班级', ''],
['家校', ''],
['会议', ''],
['场馆', ''],
['申购', ''],
['申领', ''],
['物品', ''],
['通讯录', ''],
['邮箱', ''],
['工资', ''],
['财务', ''],
['成绩', ''],
]
/** 名称命中关键词时返回对应 appicon 图标键;无命中/值为空返回 ''(不渲染矢量,走原回退)。 */
export function appiconKeyOf(name: string): string {
if (!name) return ''
for (const [kw, key] of APP_ICON_RULES) {
if (name.includes(kw)) return key
}
return ''
}
+94
View File
@@ -0,0 +1,94 @@
/**
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
* > localStorage["yh-avatar"](用户自选,纯字符串)
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
* > "text"(系统默认)
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
*/
export type AvatarStyle = "text" | "silhouette";
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
removeItem(k: string): void;
}
export const AVATAR_STORAGE_KEY = "yh-avatar";
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
return raw === "text" || raw === "silhouette" ? raw : null;
}
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
function readAvatarOverride(): AvatarStyle | null {
try {
const search = (globalThis.location && globalThis.location.search) || "";
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
return null;
}
}
/**
* 生效样式合成(与 resolveEffectiveThemeState 同构):
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
* > DEFAULT_AVATAR_STYLE。
*/
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
let style: AvatarStyle | null = null;
if (storage) {
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
}
if (!style) style = DEFAULT_AVATAR_STYLE;
return readAvatarOverride() || style;
}
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(AVATAR_STORAGE_KEY, style);
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.removeItem(AVATAR_STORAGE_KEY);
} catch {
/* 隐私模式静默 */
}
}
/**
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
*/
export function pickAvatarText(name: string | null | undefined): string {
let s = String(name ?? "");
const at = s.indexOf("@");
if (at > -1) s = s.slice(0, at);
const ch = [...s.trim()][0];
if (ch === undefined) return "";
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
if (/^[0-9]$/.test(ch)) return "";
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
return ch;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。 * 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。 * 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。 * 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
*/ */
+19 -5
View File
@@ -30,7 +30,9 @@
]" ]"
> >
<a @click="linkPage(citem.webAddr, citem)"> <a @click="linkPage(citem.webAddr, citem)">
<img :src="$utils.appIconPath(citem.icon)" /> <AppIcon v-if="vectorIconOf(citem)" :name="vectorIconOf(citem)" scene="tile"
:tint="citem.iconColor" />
<img v-else :src="$utils.appIconPath(citem.icon)" />
<div class="app-name">{{ citem.name }}</div> <div class="app-name">{{ citem.name }}</div>
</a> </a>
</ContextMenu> </ContextMenu>
@@ -60,10 +62,10 @@
]" ]"
> >
<a @click="linkPage(item.webAddr, item)"> <a @click="linkPage(item.webAddr, item)">
<img <AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:src="requireImg('defappicon.png')" :tint="item.iconColor" />
v-real-img="$utils.appIconPath(item.icon)" <img v-else :src="requireImg('defappicon.png')"
/> v-real-img="$utils.appIconPath(item.icon)" />
<div class="app-name">{{ item.name }}</div> <div class="app-name">{{ item.name }}</div>
</a> </a>
</ContextMenu> </ContextMenu>
@@ -79,6 +81,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import AppIcon from "@/components/AppIcon.vue";
import vuescroll from "vuescroll"; import vuescroll from "vuescroll";
import { ref, onMounted, onUnmounted, computed, watch } from "vue"; import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
@@ -92,6 +95,17 @@ import UserSettingApi from "@/api/UserSettingApi";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { bschLinkPage } from "@/utils/bschLinkPage"; import { bschLinkPage } from "@/utils/bschLinkPage";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { appiconKeyOf } from "@/constants/appIconRules";
/**
* 矢量渲染判定(方案 §5.4 回退链,与 index.vue / SearchPanel.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(app: any): string {
if (app?.iconKey) return app.iconKey;
if (app?.icon) return "";
return appiconKeyOf(app?.name || "");
}
// 仅在内容溢出时显示原生滚动条,避免空内容时也常驻滚动条 // 仅在内容溢出时显示原生滚动条,避免空内容时也常驻滚动条
const scrollOps = { const scrollOps = {
+514 -21
View File
@@ -49,8 +49,7 @@
<div class="form-row"> <div class="form-row">
<span class="lb">头像</span> <span class="lb">头像</span>
<div class="ctl"> <div class="ctl">
<img class="avatar-img avatar-img-sm" :src="requireImg('user-avatar-default.png')" <UserAvatar :src="store.userInfo.profilePicture" :name="store.userInfo.name" :size="40" placement="light" />
v-real-img="store.userInfo.profilePicture" alt="" />
<button class="btn ghost sm" @click="setPane('avatar')">更换头像</button> <button class="btn ghost sm" @click="setPane('avatar')">更换头像</button>
</div> </div>
</div> </div>
@@ -88,8 +87,8 @@
</div> </div>
</div> </div>
<div class="pane-foot"> <div class="pane-foot">
<!-- 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) --> <!-- 手机/邮箱保存(任务B 2026-10-02):走 /api/user/UpdateContact,后端经开放接口直写 UserBase -->
<button class="btn primary" disabled title="接口待 UserBase 支持本人自助修改,本轮暂不可用">保存修改</button> <button class="btn primary" :disabled="profileSaving" @click="onSaveProfile">保存修改</button>
<button class="btn plain" @click="resetProfileForm">重置</button> <button class="btn plain" @click="resetProfileForm">重置</button>
</div> </div>
</div> </div>
@@ -136,8 +135,7 @@
<h4>当前头像</h4> <h4>当前头像</h4>
<div class="avatar-pad"> <div class="avatar-pad">
<div class="avatar-box"> <div class="avatar-box">
<img class="avatar-img" :src="requireImg('user-avatar-default.png')" <UserAvatar :src="store.userInfo.profilePicture" :name="store.userInfo.name" :size="120" placement="light" />
v-real-img="store.userInfo.profilePicture" alt="" />
<div class="avatar-ops"> <div class="avatar-ops">
<div class="avatar-btns"> <div class="avatar-btns">
<!-- 直传逻辑移植 UserInfoModal:action/headers/校验/成功回调照抄 --> <!-- 直传逻辑移植 UserInfoModal:action/headers/校验/成功回调照抄 -->
@@ -170,6 +168,59 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 默认头像样式(默认头像轮 2026-09-30):未上传头像时的兜底形态,链路与主题同构(用户键 > 学校键 > 系统默认)。
示例即 <UserAvatar> 实时渲染:首字示例带 name(样式生效即显字),剪影示例无 name(恒剪影);
:key=myAvatarTick 使 localStorage 直写后重挂组件、示例即时切换(resolveAvatarStyle 非响应式) -->
<div class="sec-card">
<h4>默认头像样式 <span class="tag">未上传头像时生效</span></h4>
<div class="style-grid">
<div
:class="['style-opt', { sel: myAvatarEffective === 'text' }]"
@click="pickMyAvatarStyle('text')"
>
<UserAvatar :key="myAvatarTick" :name="store.userInfo.name" :size="48" placement="light" />
<span class="sn">首字</span>
<i v-if="myAvatarEffective === 'text'" class="tick">✓</i>
</div>
<div
:class="['style-opt', { sel: myAvatarEffective === 'silhouette' }]"
@click="pickMyAvatarStyle('silhouette')"
>
<UserAvatar :key="myAvatarTick" :size="48" placement="light" />
<span class="sn">剪影</span>
<i v-if="myAvatarEffective === 'silhouette'" class="tick">✓</i>
</div>
</div>
<div class="pane-foot style-foot">
<span class="origin">当前来源:<b>{{ myAvatarOriginLabel }}</b></span>
<button class="btn plain" :disabled="myAvatarOrigin !== 'mine'" @click="followSchoolAvatar">跟随学校默认</button>
</div>
</div>
<!-- 本校默认头像样式(仅管理员):写入 sch_settings.avatar_style,契约复刻 schTheme 三接口 -->
<div v-if="isAdmin" class="sec-card">
<h4>本校默认头像样式</h4>
<div class="style-grid">
<div
:class="['style-opt', { sel: schAvatarCandidate === 'text' }]"
@click="schAvatarCandidate = 'text'"
>
<span class="sn">首字</span>
</div>
<div
:class="['style-opt', { sel: schAvatarCandidate === 'silhouette' }]"
@click="schAvatarCandidate = 'silhouette'"
>
<span class="sn">剪影</span>
</div>
</div>
<div class="pane-foot style-foot">
<span class="origin">当前已配置:<b>{{ schAvatarConfigured ? AVATAR_STYLE_LABEL[schAvatarConfigured] : "未配置(系统默认:首字)" }}</b></span>
<span class="btns">
<button class="btn primary" :disabled="schAvatarSaving" @click="onSaveSchAvatar">保存为本校默认</button>
<button class="btn plain" :disabled="schAvatarSaving || !schAvatarConfigured" @click="onClearSchAvatar">跟随系统默认(清空)</button>
</span>
</div>
</div>
</div> </div>
<!-- 我的主题(所有人;主题设置 2026-09-30) --> <!-- 我的主题(所有人;主题设置 2026-09-30) -->
@@ -192,6 +243,23 @@
<i v-if="myEffective.theme === t.key" class="tick">✓</i> <i v-if="myEffective.theme === t.key" class="tick">✓</i>
</div> </div>
</div> </div>
<!-- 侧栏样式(默认头像轮 2026-09-30):状态字段 theme.ts 早已有(sideFollow/sideDeep/gradOn),此处只补 UI;
即时生效即入 localStorage(与 Notice 配色胶囊一致),无保存按钮;开关样式照搬 ThemeSwitcher .sw -->
<div class="sec-card side-card">
<h4>侧栏样式</h4>
<label class="tp-row">
<span>侧栏跟随顶栏色</span>
<span class="sw"><input type="checkbox" v-model="sideFollow" @change="pickSide({ sideFollow })" /><i></i></span>
</label>
<label class="tp-row">
<span>侧栏深色(关=浅色)</span>
<span class="sw"><input type="checkbox" v-model="sideDeep" @change="pickSide({ sideDeep })" /><i></i></span>
</label>
<label class="tp-row">
<span>顶部过渡色(渐变)</span>
<span class="sw"><input type="checkbox" v-model="gradOn" @change="pickSide({ gradOn })" /><i></i></span>
</label>
</div>
<div class="pane-foot theme-foot"> <div class="pane-foot theme-foot">
<!-- 删用户键后回落学校默认/系统默认(§3.3) --> <!-- 删用户键后回落学校默认/系统默认(§3.3) -->
<button class="btn plain" @click="followSchoolTheme" :disabled="myOrigin !== 'mine'">跟随学校默认</button> <button class="btn plain" @click="followSchoolTheme" :disabled="myOrigin !== 'mine'">跟随学校默认</button>
@@ -203,6 +271,75 @@
</div> </div>
</div> </div>
<!-- 我的壁纸(所有人;2026-10-02 任务A:能力复制自 Header「设置壁纸」模态框——点选即保存+上传,
模态框保留不动;管理员另有「外观→壁纸设置」管理 pane(上传入库/删除/应用),两者共用同一组接口) -->
<div v-else-if="curPane === 'mywallpaper'" class="pane">
<div class="pane-head">
<h3>我的壁纸</h3>
<p>选择桌面背景壁纸:系统与校区壁纸直接选用,也可上传个人壁纸;点击缩略图即保存生效。</p>
</div>
<div class="sec-card">
<h4>系统壁纸 <span class="tag">平台内置</span></h4>
<div class="wall-grid">
<div v-if="myWallGroups.sys.length === 0" class="logo-empty">暂无壁纸</div>
<div
v-for="w in myWallGroups.sys"
:key="w.id"
:class="['wall-item', { sel: myWallSelected === w.url }]"
:style="{ background: 'center / cover no-repeat url(' + w.url + ')' }"
@click="onPickMyWallpaper(w)"
>
<i class="tick">✓</i>
</div>
</div>
</div>
<div class="sec-card">
<h4>校区壁纸 <span class="tag">本校上传</span></h4>
<div class="wall-grid">
<div v-if="myWallGroups.sch.length === 0" class="logo-empty">暂无壁纸</div>
<div
v-for="w in myWallGroups.sch"
:key="w.id"
:class="['wall-item', { sel: myWallSelected === w.url }]"
:style="{ background: 'center / cover no-repeat url(' + w.url + ')' }"
@click="onPickMyWallpaper(w)"
>
<i class="tick">✓</i>
</div>
</div>
</div>
<!-- 个人上传壁纸(type=2):普通用户上传即入此组,可选用可删除;无个人壁纸时整卡不占位 -->
<div v-if="myWallGroups.mine.length > 0" class="sec-card">
<h4>我的上传 <span class="tag">仅自己可见</span></h4>
<div class="wall-grid">
<div
v-for="w in myWallGroups.mine"
:key="w.id"
:class="['wall-item', { sel: myWallSelected === w.url }]"
:style="{ background: 'center / cover no-repeat url(' + w.url + ')' }"
@click="onPickMyWallpaper(w)"
>
<i v-if="w.isEdit" class="del" title="删除" @click.stop="onDeleteMyWallpaper(w)">×</i>
<i class="tick">✓</i>
</div>
</div>
</div>
<div class="sec-card">
<h4>上传壁纸</h4>
<div class="form-row form-row--last form-row--top">
<span class="lb">选择图片</span>
<div class="ctl">
<Upload :on-success="onMyWallpaperUploaded" :show-upload-list="false"
:action="$conf.serviceHost + '/api/wallpaperUpload'"
accept="image/jpeg, image/jpg, image/png" :with-credentials="true" :headers="uploadHeaders">
<button class="btn ghost sm">选择文件</button>
</Upload>
<span class="hint">建议 1920×1080 以上,≤ 5MB;上传后进入「我的上传」可选</span>
</div>
</div>
</div>
</div>
<!-- 本校默认(仅管理员;主题设置 2026-09-30) --> <!-- 本校默认(仅管理员;主题设置 2026-09-30) -->
<div v-else-if="curPane === 'schooltheme'" class="pane"> <div v-else-if="curPane === 'schooltheme'" class="pane">
<div class="pane-head"> <div class="pane-head">
@@ -449,13 +586,13 @@ import { ref, reactive, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { requireImg } from "@/utils/hook/useImg";
import SysSetting from "@/views/SysSetting.vue"; import SysSetting from "@/views/SysSetting.vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
import SysSettingApi from "@/api/SysSettingApi"; import SysSettingApi from "@/api/SysSettingApi";
import UserSettingApi from "@/api/UserSettingApi"; import UserSettingApi from "@/api/UserSettingApi";
import MenuBarApi from "@/api/MenuBarApi"; import MenuBarApi from "@/api/MenuBarApi";
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import UserAvatar from "@/components/UserAvatar.vue";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeDict } from "@/types"; import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils"; import Utils from "@/utils";
@@ -464,7 +601,7 @@ import { Message } from "view-ui-plus";
/** /**
* 设置中心(/settings):左 216px 分组导航卡 + 右内容卡(B 方案左右双卡)。 * 设置中心(/settings):左 216px 分组导航卡 + 右内容卡(B 方案左右双卡)。
* 五个 pane 已对接真实数据(设置对接 2026-09-30,零新接口): * 五个 pane 已对接真实数据(设置对接 2026-09-30,零新接口):
* profile 读 store.userInfo(保存占位——接口待 UserBase 支持本人自助修改,本轮不做); * profile 读 store.userInfo(手机/邮箱本人可改:UpdateContact 直写 UserBase,2026-10-02 任务B);
* password 走 updatePassword + 确认登出流程(照抄 UserInfoModal); * password 走 updatePassword + 确认登出流程(照抄 UserInfoModal);
* avatar 移植 UserInfoModal 直传上传(预设色块/「使用默认头像」保持占位); * avatar 移植 UserInfoModal 直传上传(预设色块/「使用默认头像」保持占位);
* logo/wallpaper 管理列表 + 直传 + 删除照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断), * logo/wallpaper 管理列表 + 直传 + 删除照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断),
@@ -480,7 +617,7 @@ const isAdmin = computed(() => !!store.getIsAdmin);
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */ /** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */
const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend", "schooltheme"]; const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend", "schooltheme"];
const ALL_PANES = ["profile", "password", "avatar", "mytheme", "schooltheme", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"]; const ALL_PANES = ["profile", "password", "avatar", "mytheme", "mywallpaper", "schooltheme", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"];
const curPane = ref<string>("profile"); const curPane = ref<string>("profile");
@@ -522,6 +659,8 @@ const NAV_ACCOUNT: NavItem[] = [
]; ];
const NAV_THEME: NavItem[] = [ const NAV_THEME: NavItem[] = [
{ key: "mytheme", name: "我的主题", icon: svgWrap('<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18c1.5 0 2-1 2-2s-1-1.5-1-2.5 1-2 2.5-2H19a2 2 0 0 0 2-2 9 9 0 0 0-9-9z"/><circle cx="7.5" cy="11" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="11" r="1"/>') }, { key: "mytheme", name: "我的主题", icon: svgWrap('<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18c1.5 0 2-1 2-2s-1-1.5-1-2.5 1-2 2.5-2H19a2 2 0 0 0 2-2 9 9 0 0 0-9-9z"/><circle cx="7.5" cy="11" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="11" r="1"/>') },
// 我的壁纸(2026-10-02 任务A):普通用户壁纸自选 pane,能力复制自 Header 设置壁纸模态框(模态框保留不动)
{ key: "mywallpaper", name: "我的壁纸", icon: svgWrap('<path d="M5 3.5A9 9 0 1 0 20.5 12H5z"/><path d="M5 3.5V12h15.5"/>') },
{ key: "schooltheme", name: "本校默认", icon: svgWrap('<path d="M4 21V8l8-5 8 5v13"/><path d="M4 21h16"/><path d="M12 11v4"/><circle cx="12" cy="9" r="2"/>'), adminOnly: true }, { key: "schooltheme", name: "本校默认", icon: svgWrap('<path d="M4 21V8l8-5 8 5v13"/><path d="M4 21h16"/><path d="M12 11v4"/><circle cx="12" cy="9" r="2"/>'), adminOnly: true },
]; ];
const NAV_APPEARANCE: NavItem[] = [ const NAV_APPEARANCE: NavItem[] = [
@@ -550,15 +689,31 @@ const navGroups = computed(() => {
})); }));
}); });
/* ===== profile:读 store.userInfo(真实展示;保存占位) ===== */ /* ===== profile:读 store.userInfo(真实展示;手机/邮箱编辑值初始同源;工号暂以登录账号展示——接口未提供独立工号字段) ===== */
const profileForm = reactive({ mobile: "", email: "" }); /** 所属部门=组路径(BoaUser.groups 是「挂载组→…→根」近→远链):取学校组(type=2)到链首的区段反向 join("/"),
/** userInfo 派生展示(手机/邮箱编辑值初始同源;工号暂以登录账号展示——接口未提供独立工号字段) */ * 如「云海小学/校长室」;链上无学校组(区域/学段直属)时回退挂载组名;链空再回退 dept.name(dept 是 BoaGroup 对象,
* 直接展示会成 [object Object]——本 helper 即该 bug 的修复口径,他处如需展示部门也复用此口径)。 */
function deptPathOf(u: any): string {
if (typeof u.deptName === "string" && u.deptName.trim()) return u.deptName.trim();
const groups: any[] = Array.isArray(u.groups) ? u.groups : [];
if (groups.length === 0) {
const dn = u.dept && typeof u.dept.name === "string" ? u.dept.name.trim() : "";
return dn || "-";
}
const schoolIdx = groups.findIndex(g => !!g && g.type === 2);
const seg = schoolIdx >= 0 ? groups.slice(0, schoolIdx + 1) : groups.slice(0, 1);
const names = seg
.map(g => (g && typeof g.name === "string" ? g.name.trim() : ""))
.filter(Boolean)
.reverse();
return names.length > 0 ? names.join("/") : "-";
}
const profileView = computed(() => { const profileView = computed(() => {
const u: any = store.userInfo || {}; const u: any = store.userInfo || {};
return { return {
name: u.name || "-", name: u.name || "-",
account: u.account || "-", account: u.account || "-",
deptName: u.deptName || u.dept || "-", deptName: deptPathOf(u),
phone: u.phone || "", phone: u.phone || "",
email: u.email || "", email: u.email || "",
}; };
@@ -575,7 +730,48 @@ function resetProfileForm() {
profileForm.mobile = profileView.value.phone; profileForm.mobile = profileView.value.phone;
profileForm.email = profileView.value.email; profileForm.email = profileView.value.email;
} }
/** 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) */
/* ===== 手机/邮箱保存(任务B 2026-10-02):/api/user/UpdateContact → UserBase /oapi/user/self/contact ===== */
const profileSaving = ref(false);
/** 前端校验与后端同口径(UserBase ValidatorUtil.isPhone / CONTACT_EMAIL),双端拦截不一致数据 */
const PHONE_RE = /^1[3-9]\d{9}$/;
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
async function onSaveProfile() {
const phone = profileForm.mobile.trim();
const email = profileForm.email.trim();
if (!phone && !email) {
Message.warning("手机号与邮箱至少填写一项");
return;
}
if (phone && !PHONE_RE.test(phone)) {
Message.warning("手机号格式不正确");
return;
}
if (email && email.length > 100) {
Message.warning("邮箱长度不能超过100字符");
return;
}
if (email && !EMAIL_RE.test(email)) {
Message.warning("邮箱格式不正确");
return;
}
if (profileSaving.value) return;
profileSaving.value = true;
try {
// 成功返回 UserBase 更新后的最新 BoaUser:合并进 store.userInfo 即时回显,
// 规避 CAS principal 缓存旧值(UserBase 侧缓存已随更新驱逐,下次 GetUserInfo 也是新值)
const fresh: any = await BaseApi.updateContact(phone, email);
if (fresh && typeof fresh === "object") {
store.userInfo = { ...store.userInfo, ...fresh };
}
Message.success("保存成功");
} catch (error) {
Message.error("保存失败:" + error);
} finally {
profileSaving.value = false;
}
}
/* ===== password:updatePassword + 确认登出(流程照抄 UserInfoModal) ===== */ /* ===== password:updatePassword + 确认登出(流程照抄 UserInfoModal) ===== */
const passwordForm = reactive({ old: "", next: "", confirm: "" }); const passwordForm = reactive({ old: "", next: "", confirm: "" });
@@ -728,6 +924,67 @@ async function onApplyWallpaper() {
} }
} }
/* ===== mywallpaper:个人壁纸 pane(2026-10-02 任务A)=====
* 能力复制自 Header「设置壁纸」模态框(模态框保留不动):列表 getUserWallpaperList(type 0=系统/1=校区/2=个人)、
* 点选即保存 checkWallpaper(与模态框 onClickWallpaper 同语义)、上传走 /api/wallpaperUpload(普通用户落 type=2 个人壁纸)、
* 自己上传的可删(deleteWallById,isEdit 标记与模态框一致);选中态回显 store 用户设置 wallpaperUrl。 */
const myWallpaperList = ref<any[]>([]);
const myWallGroups = computed(() => {
const list = myWallpaperList.value || [];
return {
sys: list.filter(x => x.type === 0),
sch: list.filter(x => x.type === 1),
mine: list.filter(x => x.type === 2),
};
});
/** 当前生效壁纸(回显选中态,Header 模态框 getIsWallpaper 同源) */
const myWallSelected = computed(() => store.getCurWallPaperUrl || "");
async function loadMyWallpaperList() {
try {
const list = (await UserSettingApi.getUserWallpaperList(store.getCurSchId)) || [];
// 与 Header 模态框同序:type 升序(系统→校区→个人),同 type 按 id 升序
list.sort((a: any, b: any) => (a.type === b.type ? a.id - b.id : a.type - b.type));
myWallpaperList.value = list;
} catch (e) {
myWallpaperList.value = [];
}
}
/** 点选即保存(无保存按钮,与 Header 模态框行为一致);成功后同步 store 即时生效 */
async function onPickMyWallpaper(w: any) {
if (myWallSelected.value === w.url) return;
await UserSettingApi.checkWallpaper(store.getCurSchId, w.url);
const userSetting = store.curUserSettingInfo;
if (!!userSetting) {
userSetting.wallpaperUrl = w.url;
store.setCurUserSettingsInfo(userSetting);
}
Message.success("壁纸已应用");
}
async function onDeleteMyWallpaper(w: any) {
await MyConfirm({
title: "删除",
msg: "确定删除该壁纸吗?",
info: "删除后无法恢复!",
async onOk() {
await UserSettingApi.deleteWallById(w.id);
await loadMyWallpaperList();
},
});
}
function onMyWallpaperUploaded() {
Message.success("上传成功");
loadMyWallpaperList();
}
// 打开我的壁纸 pane 时拉一次可选列表(所有人)
watch(curPane, pane => {
if (pane === "mywallpaper") loadMyWallpaperList();
});
// 首次切到 logo / wallpaper pane 时懒加载管理列表 // 首次切到 logo / wallpaper pane 时懒加载管理列表
watch(curPane, pane => { watch(curPane, pane => {
if ((pane === "logo" || pane === "wallpaper") && isAdmin.value && logoManageList.value.length === 0 && wallpaperManageList.value.length === 0) { if ((pane === "logo" || pane === "wallpaper") && isAdmin.value && logoManageList.value.length === 0 && wallpaperManageList.value.length === 0) {
@@ -754,8 +1011,10 @@ const THEME_OPTIONS = [
{ key: "teal", name: "青碧", color: "#0d9488" }, { key: "teal", name: "青碧", color: "#0d9488" },
]; ];
/** 预览 iframe src:候选档经 URL 覆盖参数拼进 #/theme-preview(§3.2/§3.4,切候选即换 src 重载) */ /** 预览 iframe src:候选档经 URL 覆盖参数拼进 #/theme-preview(§3.2/§3.4,切候选即换 src 重载)。
侧栏三开关直写 localStorage 非响应式,靠 void myTick.value 让 src 对主题态变化保持响应(否则改开关预览不重载) */
function previewSrc(candidate: string) { function previewSrc(candidate: string) {
void myTick.value;
const cur = resolveEffectiveThemeState(); const cur = resolveEffectiveThemeState();
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set("yh-theme", candidate); q.set("yh-theme", candidate);
@@ -882,6 +1141,123 @@ watch(curPane, pane => {
if (pane === "mytheme" || pane === "schooltheme") loadSchTheme(); if (pane === "mytheme" || pane === "schooltheme") loadSchTheme();
}); });
// ===== 默认头像样式(默认头像轮 2026-09-30:用户自选 + 学校默认两级,链路与主题同构)=====
import {
AVATAR_STORAGE_KEY,
SCHOOL_DEFAULT_AVATAR_STORAGE_KEY,
resolveAvatarStyle,
saveAvatarStyle,
clearAvatarStyle,
} from "@/utils/avatar";
import type { AvatarStyle } from "@/utils/avatar";
const AVATAR_STYLE_LABEL: Record<AvatarStyle, string> = { text: "首字", silhouette: "剪影" };
// ---- 我的默认头像(所有人)----
const myAvatarCandidate = ref<AvatarStyle>(resolveAvatarStyle());
const myAvatarTick = ref(0);
const myAvatarEffective = computed(() => {
void myAvatarTick.value;
return resolveAvatarStyle();
});
const myAvatarOrigin = computed<"mine" | "school" | "system">(() => {
if (keyExists(AVATAR_STORAGE_KEY)) return "mine";
if (keyExists(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY)) return "school";
return "system";
});
const myAvatarOriginLabel = computed(
() => ({ mine: "我的选择", school: "学校默认", system: "系统默认" })[myAvatarOrigin.value]
);
/** 选样式即写键即生效(saveAvatarStyle 直写 localStorage 无响应式,靠 tick 刷示例与来源标签) */
function pickMyAvatarStyle(key: AvatarStyle) {
saveAvatarStyle(key);
myAvatarCandidate.value = key;
myAvatarTick.value += 1;
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认,与 followSchoolTheme 同构) */
function followSchoolAvatar() {
clearAvatarStyle();
myAvatarTick.value += 1;
}
// ---- 本校默认头像(仅管理员)----
const schAvatarCandidate = ref<AvatarStyle>(resolveAvatarStyle());
const schAvatarConfigured = ref<AvatarStyle | null>(null); // 已配置的学校默认(text|silhouette)
const schAvatarSaving = ref(false);
async function loadSchAvatar() {
try {
const res = await BaseApi.getSchAvatarStyle();
if (res && res.avatarStyle) {
schAvatarConfigured.value = res.avatarStyle as AvatarStyle;
schAvatarCandidate.value = res.avatarStyle as AvatarStyle;
} else {
schAvatarConfigured.value = null;
}
} catch (e) {
/* 读取失败不打扰 */
}
}
/** 保存本校默认:成功后同步本地学校键(管理员自身立即生效,与 onSaveSchTheme 同构) */
async function onSaveSchAvatar() {
if (schAvatarSaving.value) return;
schAvatarSaving.value = true;
try {
await BaseApi.saveSchAvatarStyle(schAvatarCandidate.value);
try {
localStorage.setItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY, schAvatarCandidate.value);
} catch (e) {
/* 隐私模式静默 */
}
schAvatarConfigured.value = schAvatarCandidate.value;
myAvatarTick.value += 1;
Message.success("本校默认头像样式已保存");
} catch (error) {
Message.error("保存失败:" + error);
} finally {
schAvatarSaving.value = false;
}
}
/** 跟随系统默认(清空学校配置,与 onClearSchTheme 同构) */
async function onClearSchAvatar() {
if (schAvatarSaving.value) return;
schAvatarSaving.value = true;
try {
await BaseApi.clearSchAvatarStyle();
try {
localStorage.removeItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY);
} catch (e) {
/* 隐私模式静默 */
}
schAvatarConfigured.value = null;
myAvatarTick.value += 1;
Message.success("已恢复系统默认");
} catch (error) {
Message.error("操作失败:" + error);
} finally {
schAvatarSaving.value = false;
}
}
// 打开头像 pane 时拉一次学校默认头像配置(管理员视角;失败静默)
watch(curPane, pane => {
if (pane === "avatar" && isAdmin.value) loadSchAvatar();
});
// ---- 侧栏样式三开关(mytheme pane;即时生效即入 localStorage,无保存按钮)----
const sideFollow = ref(resolveEffectiveThemeState().sideFollow);
const sideDeep = ref(resolveEffectiveThemeState().sideDeep);
const gradOn = ref(resolveEffectiveThemeState().gradOn);
function pickSide(patch: Partial<ThemeState>) {
saveThemeState({ ...resolveEffectiveThemeState(), ...patch } as ThemeState);
myTick.value += 1;
}
/* ===== sidebar:真实边栏管理(2026-09-30 二轮自 Header MenuBarModal 迁入:接口/字段/校验/直传/确认删除语义照抄) ===== */ /* ===== sidebar:真实边栏管理(2026-09-30 二轮自 Header MenuBarModal 迁入:接口/字段/校验/直传/确认删除语义照抄) ===== */
const sidebarList = ref<any[]>([]); const sidebarList = ref<any[]>([]);
const sidebarLoading = ref(false); const sidebarLoading = ref(false);
@@ -911,11 +1287,12 @@ const sidebarAllChecked = computed(() => sidebarForm.roleIds.length == RoleTypeD
const sidebarIndeterminate = computed(() => sidebarForm.roleIds.length > 0 && sidebarForm.roleIds.length < RoleTypeDict.length); const sidebarIndeterminate = computed(() => sidebarForm.roleIds.length > 0 && sidebarForm.roleIds.length < RoleTypeDict.length);
const sidebarColumns = ref([ const sidebarColumns = ref([
{ title: "名称", key: "name" }, { title: "名称", key: "name", minWidth: 120 },
{ title: "图标", slot: "icon" }, { title: "图标", slot: "icon", width: 70, align: "center" },
{ title: "角色", width: 150, slot: "roleIds" }, { title: "角色", width: 150, slot: "roleIds" },
{ title: "地址", key: "url", width: 150, tooltip: true }, // 地址可能很长:minWidth 保证基础宽度 + tooltip 超长省略号悬浮全显(不撑爆表格)
{ title: "打开方式", key: "linkType" }, { title: "地址", key: "url", minWidth: 220, tooltip: true },
{ title: "打开方式", key: "linkType", width: 100, align: "center" },
{ {
title: "系统页面", title: "系统页面",
key: "isNative", key: "isNative",
@@ -930,6 +1307,7 @@ const sidebarColumns = ref([
{ {
title: "操作", title: "操作",
width: 110, width: 110,
fixed: "right",
align: "center", align: "center",
render: (h: any, params: any) => { render: (h: any, params: any) => {
const disabled = !params.row.isEdit, const disabled = !params.row.isEdit,
@@ -1421,6 +1799,116 @@ function openBackend() {
display: block; display: block;
} }
/* 侧栏样式三开关(默认头像轮 2026-09-30):tp-row/sw 照搬 YschTheme ThemeSwitcher.vue:234-273(38×21 轨道、17px 位移) */
.side-card {
.tp-row + .tp-row {
margin-top: 10px;
}
}
.tp-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
color: var(--text-2, #5a6478);
cursor: pointer;
padding: 0 2px;
}
.sw {
position: relative;
width: 38px;
height: 21px;
flex: 0 0 auto;
}
.sw input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d7dce6;
transition: background 0.2s;
pointer-events: none;
}
.sw i::after {
content: "";
position: absolute;
top: 2.5px;
left: 2.5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
transition: transform 0.2s;
}
.sw input:checked + i {
background: var(--brand, #577afb);
}
.sw input:checked + i::after {
transform: translateX(17px);
}
/* 默认头像样式二选一(默认头像轮 2026-09-30):卡片规格对齐 theme-item */
.style-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-width: 320px;
padding: 12px 0 4px;
}
.style-opt {
position: relative;
height: 112px;
border: 2px solid #eef0f3;
border-radius: 10px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
cursor: pointer;
transition: all 0.18s;
&:hover {
border-color: #5979ea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.sel {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
}
.sn {
font-size: 13.5px;
color: #2c3138;
}
.tick {
display: flex;
}
}
.style-foot {
align-items: center;
justify-content: space-between;
.origin {
font-size: 12.5px;
color: #999;
b {
color: #2c3138;
}
}
.btns {
display: flex;
gap: 10px;
}
}
/* 壁纸 / LOGO 网格 */ /* 壁纸 / LOGO 网格 */
.wall-grid { .wall-grid {
display: grid; display: grid;
@@ -1584,9 +2072,14 @@ function openBackend() {
} }
} }
/* 边栏管理(2026-09-30 二轮自 MenuBarModal 迁入) */ /* 边栏管理(2026-09-30 二轮自 MenuBarModal 迁入;列宽微调 2026-10-02 任务D) */
.side-toolbar { .side-toolbar {
padding: 2px 0 10px; /* 与下方表格以细分隔线区隔(同 sec-card 内表单分行的 #eef0f3 口径),间距收紧 */
display: flex;
align-items: center;
padding: 4px 0 10px;
border-bottom: 1px solid #eef0f3;
margin-bottom: 8px;
} }
.sidebar-table { .sidebar-table {
width: 100%; width: 100%;
+44 -28
View File
@@ -4,25 +4,40 @@
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。 档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。 纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container), DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container),
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。 --> 观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。
默认头像轮(2026-09-30):logo 换云海图形、用户胶囊补三态头像圆 + icon-logout、
右侧补静态配色胶囊、左菜单换 iconfont + 可展开子菜单(预览 markup 落后于通道皮肤修复)。 -->
<div class="theme-preview-root"> <div class="theme-preview-root">
<header class="header-container fix"> <header class="header-container fix">
<span class="logo-badge fl"><span class="logo-mark">云海</span></span> <span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
<div class="delimiter fl align-middle"></div> <div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">管理端预览</h4> <h4 class="fl align-middle">管理端预览</h4>
<div class="school-bg"></div> <div class="school-bg"></div>
<div class="header-aside fr"> <div class="header-aside fr">
<UserAvatar :size="28" placement="brand" />
<span class="name fl align-middle text-ellipsis">演示用户</span> <span class="name fl align-middle text-ellipsis">演示用户</span>
<div class="delimiter fl align-middle"></div> <div class="delimiter fl align-middle"></div>
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i>⎋</i></a> <a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i class="iconfont icon-logout"></i></a>
</div> </div>
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
置于 header-aside 之后:本页不吃 .layout-wrap 皮肤(flex order 失效、走 float),
float:right 的 DOM 靠后者居左——才能复刻 Notice「配色在左、用户胶囊在右」 -->
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
</header> </header>
<div class="left-menu"> <div class="left-menu">
<div class="menu-top-box"></div> <div class="menu-top-box"></div>
<ul> <ul>
<li v-for="(m, i) in menus" :key="m" :class="{ active: i === 1 }"> <li><a href="javascript:void(0);"><i class="iconfont icon-shouye"></i>工作台</a></li>
<a href="javascript:void(0);"><span class="menu-dot"></span>{{ m }}</a> <li>
<a href="javascript:void(0);"><i class="iconfont icon-liebiao"></i>通知管理<i class="arrow-down"></i></a>
</li> </li>
<div class="child-item-wrap">
<li class="child-item active"><a href="javascript:void(0);">通知列表</a></li>
<li class="child-item"><a href="javascript:void(0);">通知设置</a></li>
</div>
<li><a href="javascript:void(0);"><i class="iconfont icon-tongji"></i>统计分析</a></li>
<li><a href="javascript:void(0);"><i class="iconfont icon-apps"></i>应用中心</a></li>
<li><a href="javascript:void(0);"><i class="iconfont icon-shezhi"></i>系统设置</a></li>
</ul> </ul>
<div class="clear-box"></div> <div class="clear-box"></div>
</div> </div>
@@ -40,17 +55,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
import UserAvatar from "@/components/UserAvatar.vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
/** /**
* 主题预览路由页:档位来源 = URL 覆盖参数(?yh-theme= / ?yh-side= / ?yh-grad=,由设置页 iframe src 拼入, * 主题预览路由页:档位来源 = URL 覆盖参数(?yh-theme= / ?yh-side= / ?yh-grad=,由设置页 iframe src 拼入,
* 切候选档即换 src 重载),init 启动段按合成优先级挂档——本组件不读写 localStorage、不提供任何交互。 * 切候选档即换 src 重载),init 启动段按合成优先级挂档——本组件不读写 localStorage、不提供任何交互。
* 直开(不经设置页、未登录)也可渲染:本页由 App.vue 顶层条件分支挂载,不依赖桌面框架与登录态。 * 直开(不经设置页、未登录)也可渲染:本页由 App.vue 顶层条件分支挂载,不依赖桌面框架与登录态。
* 头像圆走 UserAvatar(默认头像轮 2026-09-30):无 src 无 name → 恒剪影,brand 场景 = S1 玻璃底。
*/ */
const { $conf } = useCurrentInstance(); const { $conf } = useCurrentInstance();
const menus = ["工作台", "通知管理", "统计分析", "应用中心", "系统设置"];
const columns = [ const columns = [
{ type: "index", title: "序号", width: 70, align: "center" }, { type: "index", title: "序号", width: 70, align: "center" },
{ title: "标题", key: "title" }, { title: "标题", key: "title" },
@@ -87,18 +102,6 @@ onMounted(() => {
} }
.header-container { .header-container {
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.22);
color: #fff;
font-size: 12px;
font-weight: 600;
}
h4 { h4 {
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
@@ -107,15 +110,28 @@ onMounted(() => {
} }
} }
.menu-dot { /* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
display: inline-block; .cap-btn {
width: 7px; display: flex;
height: 7px; align-items: center;
border-radius: 50%; gap: 6px;
background: currentColor; height: 30px;
opacity: 0.45; padding: 0 11px;
margin-right: 9px; border: 1px solid rgba(255, 255, 255, 0.34);
vertical-align: middle; border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12.5px;
}
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距(Notice 同款覆盖) */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 子菜单缩进:预览页不吃 .layout-wrap 前缀的皮肤层,骨架 a 无缩进差,补出层级(对齐皮肤 24px 缩进) */
.left-menu .child-item-wrap a {
padding-left: 59px;
} }
.path-nav .crumb-sep { .path-nav .crumb-sep {
+17 -1
View File
@@ -21,7 +21,11 @@
]" ]"
> >
<a @click="openLinkPage(item)"> <a @click="openLinkPage(item)">
<img v-if="item.type == 1" :src="$utils.appIconPath(item.icon)" /> <template v-if="item.type == 1">
<AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
:tint="item.iconColor" />
<img v-else :src="$utils.appIconPath(item.icon)" />
</template>
<img <img
v-else-if="item.type == 2" v-else-if="item.type == 2"
:src="$conf.fileHost + '/image/lightapp.default.png'" :src="$conf.fileHost + '/image/lightapp.default.png'"
@@ -135,6 +139,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import { ref, reactive, onMounted, onUnmounted, computed, watch, watchEffect, nextTick } from "vue"; import { ref, reactive, onMounted, onUnmounted, computed, watch, watchEffect, nextTick } from "vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
// @ts-ignore // @ts-ignore
@@ -166,6 +172,16 @@ const store = useDefaultStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
/**
* 矢量渲染判定(方案 §5.4 回退链,与 AppList.vue / SearchPanel.vue 同口径):
* icon_key 命中 → 矢量;有位图 URL → 位图(存量零回归);URL 空且关键词命中 → 矢量;否则默认图。
*/
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
const refApplist = ref(); const refApplist = ref();
const isDragShow = ref(false), const isDragShow = ref(false),
isDesltopAppDefault = ref(false), isDesltopAppDefault = ref(false),