Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c937f424 | ||
|
|
4d9da5e3a6 | ||
|
|
93249cf5f5 | ||
|
|
1d34e5745d | ||
|
|
f52ce4136e | ||
|
|
7e239a0c73 | ||
|
|
41f4dade91 |
+1
-1
@@ -24,7 +24,7 @@
|
||||
"pinia": "^2.1.4",
|
||||
"sass": "^1.64.1",
|
||||
"v-wave": "^3.0.2",
|
||||
"view-ui-plus": "^1.3.19",
|
||||
"view-ui-plus": "^1.3.24",
|
||||
"vite-plugin-importer": "^0.2.4",
|
||||
"vue": "^3.4.19",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
|
||||
+16
@@ -62,6 +62,7 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
store.$state.userInfo = userinfo;
|
||||
loadSchThemeDefault();
|
||||
loadSchAvatarDefault();
|
||||
if (r1.length == 0) {
|
||||
store.$state.userRoleList = [
|
||||
{
|
||||
@@ -103,6 +104,21 @@ function loadSchThemeDefault() {
|
||||
.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() {
|
||||
await loadUserMenuBarList();
|
||||
|
||||
@@ -14,6 +14,15 @@ class Base extends Abstract {
|
||||
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
|
||||
@@ -204,6 +213,29 @@ class Base extends Abstract {
|
||||
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) {
|
||||
const url = path + "/user/savUserRole";
|
||||
return this.postRequest({ url, data: info as any });
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
// 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,6 +6,12 @@
|
||||
@use "utils/utils";
|
||||
@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/small_table";
|
||||
@use "componets/my_table";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<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);">
|
||||
Hi
|
||||
<span class="ml-4">{{ userInfo.name }}</span>
|
||||
@@ -27,6 +27,7 @@
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { computed } from "vue";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import mittBus from "@/utils/mitt";
|
||||
import { StateEnum } from "@/types";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
@@ -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>
|
||||
@@ -2,9 +2,6 @@
|
||||
<header :class="headerClass">
|
||||
<img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" />
|
||||
<ul class="operate-container fix">
|
||||
<!-- <li>
|
||||
<Button @click="settingsMenu">test</Button>
|
||||
</li> -->
|
||||
<li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }">
|
||||
<div class="icon-box" v-wave @click="toggleWidgetShow">
|
||||
<SvgIcon name="header-4" color="#333333" />
|
||||
@@ -103,7 +100,9 @@
|
||||
<img :src="requireImg('userinfo/icon3.png')" alt="" />
|
||||
<span>设置LOGO</span>
|
||||
</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="" />
|
||||
<span>边栏设置</span>
|
||||
</li>
|
||||
@@ -225,7 +224,6 @@
|
||||
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
|
||||
<MsgApprovalModal ref="refMsgApproval" />
|
||||
|
||||
<MenuBarModal ref="refMenuBarModal" />
|
||||
<StatsBarModal ref="refStatsBarModal" />
|
||||
<TeacherCopilotModal ref="refTeacherCopilotModal" />
|
||||
</template>
|
||||
@@ -240,7 +238,6 @@ import { storeToRefs } from "pinia";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { requireImg } from "@/utils/hook/useImg";
|
||||
import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import MenuBarModal from "@/components/MenuBarModal.vue";
|
||||
import StatsBarModal from "@/components/StatsBarModal.vue";
|
||||
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
@@ -264,7 +261,6 @@ const baseUrl = computed(() => $conf.fileHost);
|
||||
const { unreadCount } = storeToRefs(store);
|
||||
|
||||
const refSearchInput = ref<any>();
|
||||
const refMenuBarModal = ref<any>();
|
||||
const refStatsBarModal = ref<any>();
|
||||
const refTeacherCopilotModal = ref<any>();
|
||||
|
||||
@@ -473,9 +469,10 @@ async function settingsWallpaper() {
|
||||
isWallpaperShow.value = true;
|
||||
}
|
||||
|
||||
async function settingsMenu() {
|
||||
/** 边栏设置入口改跳设置中心 pane(原 settingsMenu 开旧 MenuBarModal 的能力已于 2026-09-30 迁入 Settings.vue sidebar pane) */
|
||||
function linkSidebarSetting() {
|
||||
isUserInfoShow.value = false;
|
||||
refMenuBarModal.value.show();
|
||||
router.push("/settings?pane=sidebar");
|
||||
}
|
||||
|
||||
async function settingsStats() {
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<vuescroll>
|
||||
<div class="app-search-list">
|
||||
<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>
|
||||
</div>
|
||||
@@ -26,10 +28,22 @@
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { ref, inject, computed, onMounted, watch } from "vue";
|
||||
import vuescroll from "vuescroll";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import { appiconKeyOf } from "@/constants/appIconRules";
|
||||
import useCurrentInstance from "@/utils/hook/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 searchName = ref();
|
||||
|
||||
@@ -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>
|
||||
@@ -15,7 +15,7 @@
|
||||
:headers="uploadHeaders"
|
||||
>
|
||||
<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>
|
||||
</Upload>
|
||||
@@ -67,8 +67,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, reactive } from "vue";
|
||||
import { requireImg } from "@/utils/hook/useImg";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import mittBus from "@/utils/mitt";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import Utils from "@/utils";
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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
@@ -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 对齐报告)。
|
||||
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
||||
*/
|
||||
|
||||
+19
-5
@@ -30,7 +30,9 @@
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
</ContextMenu>
|
||||
@@ -60,10 +62,10 @@
|
||||
]"
|
||||
>
|
||||
<a @click="linkPage(item.webAddr, item)">
|
||||
<img
|
||||
:src="requireImg('defappicon.png')"
|
||||
v-real-img="$utils.appIconPath(item.icon)"
|
||||
/>
|
||||
<AppIcon v-if="vectorIconOf(item)" :name="vectorIconOf(item)" scene="tile"
|
||||
:tint="item.iconColor" />
|
||||
<img v-else :src="requireImg('defappicon.png')"
|
||||
v-real-img="$utils.appIconPath(item.icon)" />
|
||||
<div class="app-name">{{ item.name }}</div>
|
||||
</a>
|
||||
</ContextMenu>
|
||||
@@ -79,6 +81,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import vuescroll from "vuescroll";
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
@@ -92,6 +95,17 @@ import UserSettingApi from "@/api/UserSettingApi";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { bschLinkPage } from "@/utils/bschLinkPage";
|
||||
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 = {
|
||||
|
||||
+514
-21
@@ -49,8 +49,7 @@
|
||||
<div class="form-row">
|
||||
<span class="lb">头像</span>
|
||||
<div class="ctl">
|
||||
<img class="avatar-img avatar-img-sm" :src="requireImg('user-avatar-default.png')"
|
||||
v-real-img="store.userInfo.profilePicture" alt="" />
|
||||
<UserAvatar :src="store.userInfo.profilePicture" :name="store.userInfo.name" :size="40" placement="light" />
|
||||
<button class="btn ghost sm" @click="setPane('avatar')">更换头像</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,8 +87,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-foot">
|
||||
<!-- 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) -->
|
||||
<button class="btn primary" disabled title="接口待 UserBase 支持本人自助修改,本轮暂不可用">保存修改</button>
|
||||
<!-- 手机/邮箱保存(任务B 2026-10-02):走 /api/user/UpdateContact,后端经开放接口直写 UserBase -->
|
||||
<button class="btn primary" :disabled="profileSaving" @click="onSaveProfile">保存修改</button>
|
||||
<button class="btn plain" @click="resetProfileForm">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,8 +135,7 @@
|
||||
<h4>当前头像</h4>
|
||||
<div class="avatar-pad">
|
||||
<div class="avatar-box">
|
||||
<img class="avatar-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-ops">
|
||||
<div class="avatar-btns">
|
||||
<!-- 直传逻辑移植 UserInfoModal:action/headers/校验/成功回调照抄 -->
|
||||
@@ -170,6 +168,59 @@
|
||||
</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>
|
||||
|
||||
<!-- 我的主题(所有人;主题设置 2026-09-30) -->
|
||||
@@ -192,6 +243,23 @@
|
||||
<i v-if="myEffective.theme === t.key" class="tick">✓</i>
|
||||
</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">
|
||||
<!-- 删用户键后回落学校默认/系统默认(§3.3) -->
|
||||
<button class="btn plain" @click="followSchoolTheme" :disabled="myOrigin !== 'mine'">跟随学校默认</button>
|
||||
@@ -203,6 +271,75 @@
|
||||
</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) -->
|
||||
<div v-else-if="curPane === 'schooltheme'" class="pane">
|
||||
<div class="pane-head">
|
||||
@@ -449,13 +586,13 @@ import { ref, reactive, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import { requireImg } from "@/utils/hook/useImg";
|
||||
import SysSetting from "@/views/SysSetting.vue";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import SysSettingApi from "@/api/SysSettingApi";
|
||||
import UserSettingApi from "@/api/UserSettingApi";
|
||||
import MenuBarApi from "@/api/MenuBarApi";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeDict } from "@/types";
|
||||
import Utils from "@/utils";
|
||||
@@ -464,7 +601,7 @@ import { Message } from "view-ui-plus";
|
||||
/**
|
||||
* 设置中心(/settings):左 216px 分组导航卡 + 右内容卡(B 方案左右双卡)。
|
||||
* 五个 pane 已对接真实数据(设置对接 2026-09-30,零新接口):
|
||||
* profile 读 store.userInfo(保存占位——接口待 UserBase 支持本人自助修改,本轮不做);
|
||||
* profile 读 store.userInfo(手机/邮箱本人可改:UpdateContact 直写 UserBase,2026-10-02 任务B);
|
||||
* password 走 updatePassword + 确认登出流程(照抄 UserInfoModal);
|
||||
* avatar 移植 UserInfoModal 直传上传(预设色块/「使用默认头像」保持占位);
|
||||
* logo/wallpaper 管理列表 + 直传 + 删除照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断),
|
||||
@@ -480,7 +617,7 @@ const isAdmin = computed(() => !!store.getIsAdmin);
|
||||
|
||||
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */
|
||||
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");
|
||||
|
||||
@@ -522,6 +659,8 @@ const NAV_ACCOUNT: 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"/>') },
|
||||
// 我的壁纸(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 },
|
||||
];
|
||||
const NAV_APPEARANCE: NavItem[] = [
|
||||
@@ -550,15 +689,31 @@ const navGroups = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
/* ===== profile:读 store.userInfo(真实展示;保存占位) ===== */
|
||||
const profileForm = reactive({ mobile: "", email: "" });
|
||||
/** userInfo 派生展示(手机/邮箱编辑值初始同源;工号暂以登录账号展示——接口未提供独立工号字段) */
|
||||
/* ===== profile:读 store.userInfo(真实展示;手机/邮箱编辑值初始同源;工号暂以登录账号展示——接口未提供独立工号字段) ===== */
|
||||
/** 所属部门=组路径(BoaUser.groups 是「挂载组→…→根」近→远链):取学校组(type=2)到链首的区段反向 join("/"),
|
||||
* 如「云海小学/校长室」;链上无学校组(区域/学段直属)时回退挂载组名;链空再回退 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 u: any = store.userInfo || {};
|
||||
return {
|
||||
name: u.name || "-",
|
||||
account: u.account || "-",
|
||||
deptName: u.deptName || u.dept || "-",
|
||||
deptName: deptPathOf(u),
|
||||
phone: u.phone || "",
|
||||
email: u.email || "",
|
||||
};
|
||||
@@ -575,7 +730,48 @@ function resetProfileForm() {
|
||||
profileForm.mobile = profileView.value.phone;
|
||||
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) ===== */
|
||||
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 时懒加载管理列表
|
||||
watch(curPane, pane => {
|
||||
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" },
|
||||
];
|
||||
|
||||
/** 预览 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) {
|
||||
void myTick.value;
|
||||
const cur = resolveEffectiveThemeState();
|
||||
const q = new URLSearchParams();
|
||||
q.set("yh-theme", candidate);
|
||||
@@ -882,6 +1141,123 @@ watch(curPane, pane => {
|
||||
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 迁入:接口/字段/校验/直传/确认删除语义照抄) ===== */
|
||||
const sidebarList = ref<any[]>([]);
|
||||
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 sidebarColumns = ref([
|
||||
{ title: "名称", key: "name" },
|
||||
{ title: "图标", slot: "icon" },
|
||||
{ title: "名称", key: "name", minWidth: 120 },
|
||||
{ title: "图标", slot: "icon", width: 70, align: "center" },
|
||||
{ title: "角色", width: 150, slot: "roleIds" },
|
||||
{ title: "地址", key: "url", width: 150, tooltip: true },
|
||||
{ title: "打开方式", key: "linkType" },
|
||||
// 地址可能很长:minWidth 保证基础宽度 + tooltip 超长省略号悬浮全显(不撑爆表格)
|
||||
{ title: "地址", key: "url", minWidth: 220, tooltip: true },
|
||||
{ title: "打开方式", key: "linkType", width: 100, align: "center" },
|
||||
{
|
||||
title: "系统页面",
|
||||
key: "isNative",
|
||||
@@ -930,6 +1307,7 @@ const sidebarColumns = ref([
|
||||
{
|
||||
title: "操作",
|
||||
width: 110,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
render: (h: any, params: any) => {
|
||||
const disabled = !params.row.isEdit,
|
||||
@@ -1421,6 +1799,116 @@ function openBackend() {
|
||||
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 网格 */
|
||||
.wall-grid {
|
||||
display: grid;
|
||||
@@ -1584,9 +2072,14 @@ function openBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 边栏管理(2026-09-30 二轮自 MenuBarModal 迁入) */
|
||||
/* 边栏管理(2026-09-30 二轮自 MenuBarModal 迁入;列宽微调 2026-10-02 任务D) */
|
||||
.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 {
|
||||
width: 100%;
|
||||
|
||||
+44
-28
@@ -4,25 +4,40 @@
|
||||
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
|
||||
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
|
||||
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">
|
||||
<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>
|
||||
<h4 class="fl align-middle">管理端预览</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<UserAvatar :size="28" placement="brand" />
|
||||
<span class="name fl align-middle text-ellipsis">演示用户</span>
|
||||
<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>
|
||||
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
|
||||
置于 header-aside 之后:本页不吃 .layout-wrap 皮肤(flex order 失效、走 float),
|
||||
float:right 的 DOM 靠后者居左——才能复刻 Notice「配色在左、用户胶囊在右」 -->
|
||||
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
|
||||
</header>
|
||||
<div class="left-menu">
|
||||
<div class="menu-top-box"></div>
|
||||
<ul>
|
||||
<li v-for="(m, i) in menus" :key="m" :class="{ active: i === 1 }">
|
||||
<a href="javascript:void(0);"><span class="menu-dot"></span>{{ m }}</a>
|
||||
<li><a href="javascript:void(0);"><i class="iconfont icon-shouye"></i>工作台</a></li>
|
||||
<li>
|
||||
<a href="javascript:void(0);"><i class="iconfont icon-liebiao"></i>通知管理<i class="arrow-down"></i></a>
|
||||
</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>
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
@@ -40,17 +55,17 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from "vue";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
/**
|
||||
* 主题预览路由页:档位来源 = URL 覆盖参数(?yh-theme= / ?yh-side= / ?yh-grad=,由设置页 iframe src 拼入,
|
||||
* 切候选档即换 src 重载),init 启动段按合成优先级挂档——本组件不读写 localStorage、不提供任何交互。
|
||||
* 直开(不经设置页、未登录)也可渲染:本页由 App.vue 顶层条件分支挂载,不依赖桌面框架与登录态。
|
||||
* 头像圆走 UserAvatar(默认头像轮 2026-09-30):无 src 无 name → 恒剪影,brand 场景 = S1 玻璃底。
|
||||
*/
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const menus = ["工作台", "通知管理", "统计分析", "应用中心", "系统设置"];
|
||||
|
||||
const columns = [
|
||||
{ type: "index", title: "序号", width: 70, align: "center" },
|
||||
{ title: "标题", key: "title" },
|
||||
@@ -87,18 +102,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
@@ -107,15 +110,28 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.45;
|
||||
margin-right: 9px;
|
||||
vertical-align: middle;
|
||||
/* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
|
||||
.cap-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
||||
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 {
|
||||
|
||||
+17
-1
@@ -21,7 +21,11 @@
|
||||
]"
|
||||
>
|
||||
<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
|
||||
v-else-if="item.type == 2"
|
||||
:src="$conf.fileHost + '/image/lightapp.default.png'"
|
||||
@@ -135,6 +139,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 BaseApi from "@/api/BaseApi";
|
||||
// @ts-ignore
|
||||
@@ -166,6 +172,16 @@ const store = useDefaultStore();
|
||||
const route = useRoute();
|
||||
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 isDragShow = ref(false),
|
||||
isDesltopAppDefault = ref(false),
|
||||
|
||||
Reference in New Issue
Block a user