Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb9f5109f | ||
|
|
5a080f502d | ||
|
|
04c12e2076 | ||
|
|
84bbf2faf4 | ||
|
|
5d7b8526f4 | ||
|
|
651e7e16f3 | ||
|
|
8af636641f | ||
|
|
8305129c73 | ||
|
|
57cb98f49e | ||
|
|
c7a295521d | ||
|
|
2f4ee76b7b | ||
|
|
27ce68a76e | ||
|
|
3a7502f106 | ||
|
|
479f7b22a7 | ||
|
|
0baba07707 | ||
|
|
6f4b3f07d4 | ||
|
|
a3ddb90a8a | ||
|
|
5a19a4ed79 | ||
|
|
9980ea1264 | ||
|
|
f5faa29018 | ||
|
|
be0dac6bfa | ||
|
|
1b812bc9c9 | ||
|
|
ac67215c78 | ||
|
|
0621ef1ede | ||
|
|
0264d41428 | ||
|
|
d1a371d257 | ||
|
|
e7f70f46a8 | ||
|
|
b774d2e733 | ||
|
|
2819f96dfd | ||
|
|
bdc8898b93 | ||
|
|
49f15b8a05 | ||
|
|
2bd7ef3b80 | ||
|
|
4f36ff9ceb | ||
|
|
b2307f583f | ||
|
|
ec696d5187 |
@@ -19,7 +19,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- name: 安装 git / curl / zip(alpine)
|
||||||
run: apk add --no-cache git curl zip
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl zip
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ card-harness.html
|
|||||||
card-harness.vite.config.ts
|
card-harness.vite.config.ts
|
||||||
src/card-harness-main.ts
|
src/card-harness-main.ts
|
||||||
|
|
||||||
|
# 小组件内置化取证 harness(同一类,本地临时)
|
||||||
|
dist_widget_harness
|
||||||
|
widget-harness.html
|
||||||
|
widget-harness.vite.config.ts
|
||||||
|
src/widget-harness-main.ts
|
||||||
|
.tmp-build
|
||||||
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
.npmrc
|
.npmrc
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>harness-取证</title>
|
<title>harness-取证</title>
|
||||||
|
<!-- 照抄 index.html:30-34:main.ts 不 import 这几个全局脚本,靠 html 提供。
|
||||||
|
缺 jquery ⇒ "$ is not defined" ⇒ v-wave / vuescroll 抛错(本项目固有坑) -->
|
||||||
|
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||||
|
<script src="./libs/jquery.min.js"></script>
|
||||||
|
<script src="./libs/lodash.min.js"></script>
|
||||||
|
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import Abstract from "@/utils/axios/abstract";
|
|
||||||
import { PageData, PageParams } from "@/types/Basics";
|
|
||||||
|
|
||||||
const path = "/api";
|
|
||||||
|
|
||||||
export interface AppCategoryItem {
|
|
||||||
id?: number;
|
|
||||||
name: string;
|
|
||||||
apiUrl: string;
|
|
||||||
dataKey: string;
|
|
||||||
paramsKey: string;
|
|
||||||
roleType: number;
|
|
||||||
cId?: number;
|
|
||||||
cDate?: string;
|
|
||||||
resDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 系统设置-应用管理(应用类别 CRUD + 模板管理) */
|
|
||||||
class AppSettingApi extends Abstract {
|
|
||||||
/** 应用分页列表 */
|
|
||||||
getAppCategoryList(params: PageParams & { roleType: number; name?: string }) {
|
|
||||||
const url = path + "/GetAppCategoryList";
|
|
||||||
return this.getRequest<PageData>({ url, params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存应用(新增/编辑) */
|
|
||||||
saveAppCategory(data: AppCategoryItem) {
|
|
||||||
const url = path + "/SaveAppCategory";
|
|
||||||
return this.postRequest({ url, data });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除应用 */
|
|
||||||
deleteAppCategory(id: number) {
|
|
||||||
const url = path + "/DelAppCategory";
|
|
||||||
return this.postRequest({ url, params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询应用详情 */
|
|
||||||
findAppCategory(id: number) {
|
|
||||||
const url = path + "/FindAppCategory";
|
|
||||||
return this.getRequest<AppCategoryItem>({ url, params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 刷新资源时间戳(上传模板后调用) */
|
|
||||||
updateResDate(id: number) {
|
|
||||||
const url = path + "/UpdateResDate";
|
|
||||||
return this.postRequest({ url, params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模板文件列表 */
|
|
||||||
getTempList(appModId: number) {
|
|
||||||
const url = path + "/GetTempList";
|
|
||||||
return this.getRequest<any[]>({ url, params: { appModId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存模板文件内容(JSON body,避免大文件内容超出 URL/请求头长度限制) */
|
|
||||||
saveTempFile(appModId: number, fileName: string, content: string) {
|
|
||||||
const url = path + "/SaveTempFile";
|
|
||||||
return this.postRequest({ url, data: { appModId, fileName, content } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new AppSettingApi();
|
|
||||||
@@ -125,7 +125,7 @@ class Base extends Abstract {
|
|||||||
const url = path + "/msgCenter/summary";
|
const url = path + "/msgCenter/summary";
|
||||||
return this.getRequest<{
|
return this.getRequest<{
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
todos: { appKey: string; count: number }[];
|
todos: { count: number }[];
|
||||||
sysUnread?: number;
|
sysUnread?: number;
|
||||||
bizUnread?: number;
|
bizUnread?: number;
|
||||||
approvalUnread?: number;
|
approvalUnread?: number;
|
||||||
@@ -226,6 +226,34 @@ class Base extends Abstract {
|
|||||||
return this.postRequest({ url });
|
return this.postRequest({ url });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== LOGO 文字(平台/学校名称,2026-10-09 LOGO 文字轮;后端契约见 SchSettingsController#schLogoText)=====
|
||||||
|
|
||||||
|
/** 读 LOGO 文字(已登录可读):{logoText: string|null, schId=实际命中层}——本校空 → 平台层 → null */
|
||||||
|
getSchLogoText() {
|
||||||
|
const url = path + "/schLogoText";
|
||||||
|
return this.getRequest<{ logoText: string | null; schId: number }>({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存 LOGO 文字(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校),≤64;空串=清空本级 */
|
||||||
|
saveSchLogoText(logoText: string) {
|
||||||
|
const url = path + "/saveSchLogoText";
|
||||||
|
return this.postRequest({ url, params: { logoText } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 默认壁纸(平台/学校两级,壁纸三级默认轮 2026-10-09;后端契约见 SchSettingsController#schWallpaper)=====
|
||||||
|
|
||||||
|
/** 读默认壁纸(已登录可读):{wallpaperUrl: string|null, schId=实际命中层}——本校空 → 平台层 → null */
|
||||||
|
getSchWallpaper() {
|
||||||
|
const url = path + "/schWallpaper";
|
||||||
|
return this.getRequest<{ wallpaperUrl: string | null; schId: number }>({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存默认壁纸(仅学校管理员/超管):URL 取管理池返回的绝对地址原样存,≤255;空串=清空本级 */
|
||||||
|
saveSchWallpaper(wallpaperUrl: string) {
|
||||||
|
const url = path + "/saveSchWallpaper";
|
||||||
|
return this.postRequest({ url, params: { wallpaperUrl } });
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 学校默认头像样式(默认头像轮 2026-09-30;后端契约见 SchSettingsController#schAvatarStyle)=====
|
// ===== 学校默认头像样式(默认头像轮 2026-09-30;后端契约见 SchSettingsController#schAvatarStyle)=====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,6 +277,35 @@ class Base extends Abstract {
|
|||||||
return this.postRequest({ url });
|
return this.postRequest({ url });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 用户外观偏好(外观偏好服务端化 2026-10-08;后端契约见 AppearanceController)=====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读当前用户外观偏好聚合:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}(紧凑编码/绝对 URL,查不到为 null)。
|
||||||
|
* 各子应用 yh-theme-init 异步补齐段的同源数据源;选择端预留(本轮设置页双写用不到)。
|
||||||
|
*/
|
||||||
|
getAppearance() {
|
||||||
|
const url = path + "/appearance";
|
||||||
|
return this.getRequest<{
|
||||||
|
myTheme: string | null;
|
||||||
|
schTheme: string | null;
|
||||||
|
avatarMy: string | null;
|
||||||
|
avatarSch: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
}>({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选主题档(紧凑编码 theme|sideDeep01|gradOn01|sideFollow01);null=跟随学校默认。设置页 fire-and-forget 双写:成功不提示、失败不回滚本地 */
|
||||||
|
saveMyTheme(themeState: string | null) {
|
||||||
|
const url = path + "/saveMyTheme";
|
||||||
|
return this.postRequest({ url, data: { themeState } as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选头像样式("text"|"silhouette");null=跟随学校默认。双写语义同 saveMyTheme */
|
||||||
|
saveMyAvatarStyle(avatarStyle: string | null) {
|
||||||
|
const url = path + "/saveMyAvatarStyle";
|
||||||
|
return this.postRequest({ url, data: { avatarStyle } as any });
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import Abstract from "@/utils/axios/abstract";
|
import Abstract from "@/utils/axios/abstract";
|
||||||
import { PageData, PageParams } from "@/types/Basics";
|
|
||||||
|
|
||||||
const path = "/api";
|
const path = "/api";
|
||||||
|
|
||||||
/** 系统设置-基础设置(机构树 / 壁纸 / Logo 管理视图) */
|
/** 门户外观管理视图(壁纸 / Logo 池;S4.7 后由设置中心「门户外观」pane 调用,机构树随「系统设置-基础设置」一起移除) */
|
||||||
class SysSettingApi extends Abstract {
|
class SysSettingApi extends Abstract {
|
||||||
/** 机构树(仅超管),已裁剪到学校层 */
|
|
||||||
getSchTree() {
|
|
||||||
const url = path + "/getSchTree";
|
|
||||||
return this.getRequest<any>({ url });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 壁纸管理列表(系统 + 目标学校,不含个人壁纸) */
|
/** 壁纸管理列表(系统 + 目标学校,不含个人壁纸) */
|
||||||
getSchWallpaperManageList(schId?: number) {
|
getSchWallpaperManageList(schId?: number) {
|
||||||
const url = path + "/getSchWallpaperManageList";
|
const url = path + "/getSchWallpaperManageList";
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Abstract from "@/utils/axios/abstract";
|
||||||
|
|
||||||
|
const path = "/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面/常用应用三级默认(used_app:BASE 平台 / SCHOOL 本校 / USER 个人)。
|
||||||
|
* 后端 UsedAppController + UsedAppServiceImpl.findById 已实现「用户 > 学校 > 系统」级联与覆盖式保存,
|
||||||
|
* 本封装只服务设置中心「桌面应用」pane(S6),不改后端接口签名。
|
||||||
|
*/
|
||||||
|
class UsedAppApi extends Abstract {
|
||||||
|
/**
|
||||||
|
* 取某角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
|
||||||
|
* @param roleType 角色(1 校管理员 / 2 教师 / 3 学生)
|
||||||
|
* @param schId 学校ID;0 时后端按登录人主学校→归属组推断
|
||||||
|
* @param isSch 学校级读取:true 且非超管时后端令 uid=schId,于是只回本校 SCHOOL 那一行;
|
||||||
|
* 超管传 true 也会被后端强制回 false(走级联,实际取到 BASE 平台行)
|
||||||
|
*/
|
||||||
|
getUsedApp(roleType: number, schId: number, isSch: boolean) {
|
||||||
|
const url = path + "/usedapp/getUsedApp";
|
||||||
|
return this.getRequest<any[]>({ url, params: { roleType, schId, isSch } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖式保存某角色本级默认桌面(同 setId+type+roleType+schId 存在则更新)。
|
||||||
|
* 数量上限 ≤8 由前端控制(后端注释口径);超管传任何 type 都会被后端强制为 BASE(0)。
|
||||||
|
*/
|
||||||
|
saveUsedApp(params: { appIds: string; type: number; roleType: number; schId?: number }) {
|
||||||
|
const url = path + "/usedapp/saveUsedApp";
|
||||||
|
return this.postRequest({ url, data: params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new UsedAppApi();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetPositionResp } from "@/types";
|
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetCatalog, WidgetPositionResp } from "@/types";
|
||||||
import { BaseDict, PageData, PageParams } from "@/types/Basics";
|
import { BaseDict, PageData, PageParams } from "@/types/Basics";
|
||||||
import Abstract from "@/utils/axios/abstract";
|
import Abstract from "@/utils/axios/abstract";
|
||||||
|
|
||||||
@@ -92,6 +92,53 @@ class UserSettings extends Abstract {
|
|||||||
const url = path + "/sortUserDesktopApp";
|
const url = path + "/sortUserDesktopApp";
|
||||||
return this.postRequest<any>({ url, data: { ids: ids.join(",") } });
|
return this.postRequest<any>({ url, data: { ids: ids.join(",") } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小组件统一取数:params 为 urlencoded 查询串(对齐旧 otherParams 口径) */
|
||||||
|
getWidgetData(code: string, params: string) {
|
||||||
|
const url = path + "/getWidgetData";
|
||||||
|
return this.getRequest<any>({ url, params: { code: code, params: params } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅 comp_type=2:按需取自定义片段内容 */
|
||||||
|
getWidgetContent(code: string) {
|
||||||
|
const url = path + "/getWidgetContent";
|
||||||
|
return this.getRequest<any>({ url, params: { code: code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
getWidgetCatalogList() {
|
||||||
|
const url = path + "/GetWidgetCatalogList";
|
||||||
|
return this.getRequest<WidgetCatalog[]>({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑全局目录(仅超管):scopeType/roleTypes/roleAlias/scopeSchIds 一并提交 */
|
||||||
|
saveWidgetCatalog(catalog: Partial<WidgetCatalog> & Record<string, any>) {
|
||||||
|
const url = path + "/SaveWidgetCatalog";
|
||||||
|
return this.postRequest<any>({ url, data: catalog });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除全局目录(仅超管),后端连带删点名表与校级覆盖行 */
|
||||||
|
deleteWidgetCatalog(id: number) {
|
||||||
|
const url = path + "/DeleteWidgetCatalog";
|
||||||
|
return this.postRequest<any>({ url, params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读本校覆盖行(编辑回显):schId 由后端取当前登录校 */
|
||||||
|
getWidgetSchoolSetting(widgetId: number) {
|
||||||
|
const url = path + "/GetWidgetSchoolSetting";
|
||||||
|
return this.getRequest<any>({ url, params: { widgetId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存本校覆盖(校管/超管):schId 由后端强制取当前登录校,前端不传 */
|
||||||
|
saveWidgetSchoolSetting(setting: Record<string, any>) {
|
||||||
|
const url = path + "/SaveWidgetSchoolSetting";
|
||||||
|
return this.postRequest<any>({ url, data: setting });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 机构树(仅超管):递归裁剪到学校层,children 即学校列表,用于可见范围多选 */
|
||||||
|
getSchTree() {
|
||||||
|
const url = path + "/getSchTree";
|
||||||
|
return this.getRequest<any>({ url });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new UserSettings();
|
export default new UserSettings();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12.0303 5L12.012 19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M12.0303 5L12.012 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M5 12H19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 329 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="14" y="14" width="92" height="92" rx="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="6 7" stroke-linecap="round"/>
|
||||||
|
<path d="M60 44v32M44 60h32" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" d="M55.25 5.19L48.75 6.11L44.75 7.12L39.25 9.04L33.75 11.62L29.75 14.06L24.84 17.75L21.25 20.99L17.75 24.84L14.06 29.75L11.62 33.75L9.04 39.25L7.57 43.25L6.58 46.75L5.58 51.75L5.04 57.25L5.1 63.75L5.58 68.25L7.12 75.25L9.04 80.75L11.62 86.25L14.06 90.25L16.6 93.75L20.99 98.75L23.75 101.32L28.25 104.9L33.75 108.38L41.75 111.93L46.75 113.42L51.75 114.42L57.25 114.96L63.75 114.9L68.25 114.42L75.25 112.88L79.75 111.37L86.25 108.38L90.25 105.94L93.75 103.4L98.75 99.01L101.32 96.25L104.9 91.75L108.38 86.25L111.93 78.25L113.42 73.25L114.42 68.25L114.96 62.75L114.9 56.25L114.42 51.75L112.88 44.75L110.96 39.25L108.38 33.75L105.94 29.75L102.25 24.84L99.01 21.25L95.16 17.75L90.25 14.06L86.25 11.62L80.75 9.04L76.75 7.57L73.25 6.58L68.25 5.58L62.75 5.04ZM55.18 13.25L59.75 13L64.25 13.17L70.25 14.11L73.75 15.03L78.25 16.64L82.25 18.56L85.75 20.67L89.75 23.58L93.18 26.75L96.42 30.25L99.33 34.25L101.44 37.75L103.36 41.75L104.97 46.25L105.99 50.25L106.92 56.75L106.88 63.75L105.9 70.25L105.25 71.65L84.25 71.65L82.25 72.05L81.15 72.75L80.04 74.25L78.82 78.75L77.41 81.75L75.25 84.72L72.64 87.25L70.25 88.97L67.25 90.44L63.75 91.44L60.25 91.76L56.25 91.44L52.75 90.44L49.75 88.97L47.36 87.25L44.75 84.72L42.59 81.75L41.18 78.75L39.97 74.25L38.9 72.75L37.75 72.02L35.75 71.65L14.75 71.65L14.1 70.25L13.08 63.25L13.17 55.75L14.11 49.75L15.18 45.75L16.64 41.75L18.56 37.75L20.67 34.25L23.58 30.25L26.75 26.82L30.25 23.58L34.25 20.67L37.75 18.56L41.75 16.64L46.25 15.03L49.75 14.11ZM59.25 18.07L58.06 18.75L57.07 20.25L57.01 27.25L57.59 28.75L58.25 29.41L59.25 29.93L60.25 29.99L61.75 29.41L62.41 28.75L62.93 27.75L62.99 20.75L62.41 19.25L61.75 18.59L60.75 18.07ZM31.25 29.66L30.43 30.25L29.66 31.25L29.55 33.25L30.07 34.25L35.25 39.26L36.75 39.59L37.75 39.46L38.75 38.79L39.46 37.75L39.59 36.75L39.49 35.75L38.95 34.75L34.25 30.07L33.25 29.55ZM86.25 29.53L84.75 30.72L81.23 34.25L80.53 35.25L80.32 36.25L80.54 37.75L81.37 38.75L82.25 39.31L83.75 39.43L85.12 38.75L89.25 34.7L90.31 33.25L90.43 31.75L89.64 30.25L88.75 29.54L87.25 29.32ZM84.75 48.23L62.25 64.76L61.75 65.05L58.75 65.09L56.75 65.55L54.75 66.51L52.62 68.25L51.51 69.75L50.55 71.75L50.14 73.25L50.01 75.25L50.14 76.75L50.74 78.75L51.75 80.6L53.25 82.37L56.25 84.26L58.25 84.86L60.25 84.99L61.75 84.86L63.75 84.26L65.6 83.25L67.37 81.75L68.49 80.25L69.45 78.25L69.91 76.25L69.95 73.25L70.24 72.75L86.78 50.25L86.96 49.25L86.72 48.75L85.75 48.04ZM20.25 57.07L19.25 57.59L18.59 58.25L18.01 59.75L18.07 60.75L18.59 61.75L19.25 62.41L20.25 62.93L27.75 62.93L29.25 61.94L29.93 60.75L29.93 59.25L29.41 58.25L28.75 57.59L27.75 57.07ZM92.25 57.07L91.25 57.59L90.59 58.25L90.01 59.75L90.07 60.75L90.59 61.75L91.25 62.41L92.25 62.93L99.75 62.93L100.94 62.25L101.93 60.75L101.93 59.25L101.41 58.25L100.75 57.59L99.75 57.07ZM58.51 70.25L60.75 70.04L62.25 70.53L63.79 71.75L64.87 73.75L64.96 75.75L64.47 77.25L63.25 78.79L61.25 79.87L59.25 79.96L57.75 79.47L56.21 78.25L55.13 76.25L55.04 74.25L55.53 72.75L56.75 71.21Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M5 12H19" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 210 B |
@@ -0,0 +1,79 @@
|
|||||||
|
<svg width="210" height="242" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5"/>
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="url(#paint0_linear_25188_157943)"/>
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="url(#paint1_linear_25188_157943)"/>
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="url(#paint2_linear_25188_157943)"/>
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="url(#paint3_linear_25188_157943)"/>
|
||||||
|
<rect y="182.438" width="210" height="27.5625" fill="url(#paint4_radial_25188_157943)"/>
|
||||||
|
<path opacity="0.6" d="M182.123 151.822C182.393 150.842 183.783 150.842 184.052 151.822L189.953 173.298C190.128 173.934 189.649 174.562 188.988 174.562H177.187C176.527 174.562 176.048 173.934 176.223 173.298L182.123 151.822Z" fill="url(#paint5_linear_25188_157943)"/>
|
||||||
|
<rect opacity="0.6" x="181.121" y="174.562" width="3.93417" height="13.125" fill="url(#paint6_linear_25188_157943)"/>
|
||||||
|
<path opacity="0.6" d="M25.9359 151.822C26.2052 150.842 27.5951 150.842 27.8644 151.822L33.7652 173.298C33.9401 173.934 33.4611 174.562 32.801 174.562H20.9993C20.3392 174.562 19.8602 173.934 20.0351 173.298L25.9359 151.822Z" fill="url(#paint7_linear_25188_157943)"/>
|
||||||
|
<rect opacity="0.6" x="24.9331" y="174.562" width="3.93417" height="13.125" fill="url(#paint8_linear_25188_157943)"/>
|
||||||
|
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC"/>
|
||||||
|
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="url(#paint9_linear_25188_157943)"/>
|
||||||
|
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="white"/>
|
||||||
|
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="url(#paint10_linear_25188_157943)"/>
|
||||||
|
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="url(#paint11_linear_25188_157943)"/>
|
||||||
|
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="url(#paint12_linear_25188_157943)"/>
|
||||||
|
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="url(#paint13_linear_25188_157943)"/>
|
||||||
|
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA"/>
|
||||||
|
<path d="M74.7124 230.804V231.984L71.7724 232.264V233.804H70.4524V232.364L66.2924 232.624L66.1124 231.344L70.4524 231.124V229.464H66.7324L66.4124 228.324C67.0124 227.63 67.539 226.864 67.9924 226.024H65.8524V224.744H68.6324C68.8724 224.157 69.0457 223.717 69.1524 223.424L70.5924 223.664C70.4057 224.144 70.259 224.504 70.1524 224.744H75.0324V226.024H69.5524C69.1124 226.85 68.639 227.604 68.1324 228.284H70.4524V226.964H71.7724V228.284H74.8924V229.464H71.7724V231.044L74.7124 230.804ZM77.5124 229.484C77.299 231.164 76.759 232.624 75.8924 233.864L74.8924 232.824C75.7857 231.504 76.2524 229.957 76.2924 228.184V225.044C78.7857 224.804 80.7324 224.364 82.1324 223.724L83.0524 224.804C81.5324 225.457 79.719 225.904 77.6124 226.144V228.224H83.6124V229.484H81.4924V233.824H80.1124V229.484H77.5124ZM81.3724 234.404V241.924H79.8924V241.144H69.7324V241.924H68.2524V234.404H81.3724ZM69.7324 239.904H79.8924V238.344H69.7324V239.904ZM69.7324 237.184H79.8924V235.624H69.7324V237.184ZM87.3524 224.664H101.712V226.064H94.5524V227.404C94.5124 228.724 94.4257 229.89 94.2924 230.904H103.332V232.284H97.1324V239.264C97.1324 239.917 97.4524 240.244 98.0924 240.244H100.632C101.112 240.244 101.466 240.11 101.692 239.844C101.972 239.537 102.159 238.604 102.252 237.044L103.612 237.484C103.452 239.43 103.152 240.624 102.712 241.064C102.339 241.424 101.732 241.604 100.892 241.604H97.6524C96.3457 241.604 95.6924 240.924 95.6924 239.564V232.284H94.0724C93.7524 234.017 93.2524 235.524 92.5724 236.804C91.319 238.99 89.3124 240.677 86.5524 241.864L85.7524 240.584C88.499 239.344 90.4124 237.71 91.4924 235.684C91.959 234.71 92.3257 233.577 92.5924 232.284H86.0124V230.904H92.8324C92.979 229.877 93.0657 228.71 93.0924 227.404V226.064H87.3524V224.664ZM117.852 228.024C117.719 228.504 117.626 228.857 117.572 229.084C118.066 231.35 118.719 233.344 119.532 235.064C120.306 233.037 120.732 230.69 120.812 228.024H117.852ZM118.872 236.544C118.086 234.97 117.426 233.204 116.892 231.244C116.546 232.124 116.152 232.944 115.712 233.704L114.812 232.604C115.999 230.35 116.792 227.33 117.192 223.544L118.592 223.784C118.499 224.61 118.352 225.564 118.152 226.644H123.432V228.024H122.132C122.052 231.357 121.459 234.21 120.352 236.584C121.406 238.344 122.639 239.697 124.052 240.644L123.232 241.864C121.859 240.904 120.666 239.59 119.652 237.924C118.679 239.524 117.439 240.864 115.932 241.944L115.172 240.724C116.732 239.644 117.966 238.25 118.872 236.544ZM110.772 233.024L110.372 234.124H114.532V235.344C114.092 236.65 113.366 237.784 112.352 238.744C113.299 239.144 114.199 239.584 115.052 240.064L114.272 241.224C113.352 240.677 112.339 240.157 111.232 239.664C109.886 240.584 108.219 241.324 106.232 241.884L105.532 240.644C107.199 240.204 108.599 239.657 109.732 239.004L106.952 237.884C107.512 236.964 107.966 236.144 108.312 235.424H105.972V234.124H108.912C109.246 233.19 109.399 232.77 109.372 232.864L110.772 233.024ZM110.972 238.164C111.972 237.35 112.706 236.437 113.172 235.424H109.792C109.459 236.104 109.099 236.737 108.712 237.324C109.512 237.604 110.266 237.884 110.972 238.164ZM107.552 224.024C108.072 224.837 108.499 225.657 108.832 226.484L107.712 227.004C107.299 226.057 106.846 225.197 106.352 224.424L107.552 224.024ZM114.732 224.444C114.319 225.35 113.806 226.217 113.192 227.044L112.112 226.464C112.752 225.557 113.246 224.697 113.592 223.884L114.732 224.444ZM106.032 227.424H109.932V223.604H111.272V227.424H114.912V228.684H111.272V228.904C112.366 229.45 113.472 230.064 114.592 230.744L113.852 231.864C112.852 231.064 111.992 230.424 111.272 229.944V232.544H109.932V228.744C109.279 230.304 108.086 231.67 106.352 232.844L105.652 231.684C107.199 230.777 108.272 229.777 108.872 228.684H106.032V227.424ZM126.272 241.744L125.972 240.384C126.546 240.45 127.052 240.484 127.492 240.484C127.999 240.484 128.252 240.19 128.252 239.604V234.724C127.319 235.07 126.532 235.344 125.892 235.544L125.532 234.144C126.439 233.93 127.346 233.657 128.252 233.324V228.804H125.852V227.424H128.252V223.644H129.652V227.424H131.572V228.804H129.652V232.744C130.292 232.464 130.912 232.164 131.512 231.844V233.264C131.099 233.477 130.479 233.77 129.652 234.144V239.964C129.652 241.15 129.092 241.744 127.972 241.744H126.272ZM133.952 232.904C133.712 236.317 132.932 239.324 131.612 241.924L130.492 240.924C131.906 238.284 132.626 234.857 132.652 230.644V224.384H143.192V229.244H139.432V231.604H143.712V232.904H139.432V235.364H142.792V241.924H141.452V240.944H136.032V241.924H134.692V235.364H138.032V232.904H133.952ZM138.032 229.244H134.052L134.032 231.604H138.032V229.244ZM136.032 239.664H141.452V236.644H136.032V239.664ZM141.792 225.724H134.052V227.944H141.792V225.724Z" fill="#969799"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_25188_157943" x1="-3.63248" y1="171.938" x2="-3.63248" y2="69.5625" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white" stop-opacity="0.5"/>
|
||||||
|
<stop offset="1" stop-color="#F2F3F5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_25188_157943" x1="140.274" y1="35.8711" x2="140.274" y2="7.875" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F2F3F5" stop-opacity="0.3"/>
|
||||||
|
<stop offset="1" stop-color="#F2F3F5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_25188_157943" x1="37.8985" y1="43.5495" x2="37.8985" y2="30.1875" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F2F3F5" stop-opacity="0.3"/>
|
||||||
|
<stop offset="1" stop-color="#F2F3F5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_25188_157943" x1="207.83" y1="139.609" x2="207.83" y2="19.6875" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white" stop-opacity="0.5"/>
|
||||||
|
<stop offset="1" stop-color="#F2F3F5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="paint4_radial_25188_157943" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(105 182.438) rotate(90) scale(27.5625 877.353)">
|
||||||
|
<stop stop-color="#EBEDF0"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.01"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="paint5_linear_25188_157943" x1="175.875" y1="148.312" x2="175.875" y2="174.562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F2F3F5"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint6_linear_25188_157943" x1="185.055" y1="187.688" x2="185.055" y2="174.562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint7_linear_25188_157943" x1="19.6875" y1="148.312" x2="19.6875" y2="174.562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F2F3F5"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint8_linear_25188_157943" x1="28.8673" y1="187.688" x2="28.8673" y2="174.562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint9_linear_25188_157943" x1="57.75" y1="65.625" x2="57.75" y2="152.25" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F2F3F5"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint10_linear_25188_157943" x1="97.3111" y1="84" x2="95.1448" y2="97.782" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#EAEDF1"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint11_linear_25188_157943" x1="97.3111" y1="102.375" x2="95.1448" y2="116.157" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#EAEDF1"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint12_linear_25188_157943" x1="97.3111" y1="120.75" x2="95.1448" y2="134.532" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#EAEDF1"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint13_linear_25188_157943" x1="-4.18762" y1="164.063" x2="19.2534" y2="229.682" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#EAEDF1"/>
|
||||||
|
<stop offset="1" stop-color="#DCDEE0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -1,12 +1,26 @@
|
|||||||
.widget-wrap {
|
.widget-wrap {
|
||||||
|
// 玻璃层令牌(方案 §6.2):深底 + 模糊 + 描边 + 投影,前景走 token,不再随壁纸漂移
|
||||||
|
--wg-glass-bg: rgba(15, 23, 42, 0.42);
|
||||||
|
--wg-glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--wg-glass-shadow: 0 10px 36px rgba(0, 0, 0, 0.3);
|
||||||
|
--wg-fg: rgba(255, 255, 255, 0.94);
|
||||||
|
--wg-fg-dim: rgba(255, 255, 255, 0.66);
|
||||||
|
--wg-fg-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
width: 380px;
|
width: 380px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: var(--wg-glass-bg);
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
border: 1px solid var(--wg-glass-border);
|
||||||
|
box-shadow: var(--wg-glass-shadow);
|
||||||
.widget-header {
|
.widget-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
color: var(--wg-fg-dim);
|
||||||
|
text-shadow: var(--wg-fg-shadow);
|
||||||
.widget-header-drag {
|
.widget-header-drag {
|
||||||
flex: 2 auto;
|
flex: 2 auto;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
@@ -18,6 +32,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
|
color: var(--wg-fg-dim);
|
||||||
|
}
|
||||||
|
&:hover svg {
|
||||||
|
color: var(--wg-fg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.operate-btn:nth-child(2) {
|
.operate-btn:nth-child(2) {
|
||||||
@@ -30,12 +48,24 @@
|
|||||||
height: calc(100% - 48px);
|
height: calc(100% - 48px);
|
||||||
.widget-item {
|
.widget-item {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
// 内容卡不透明白底。旧版内容跑在 <iframe> 里、由 iframe 自带白底提供,
|
||||||
overflow: hidden;
|
// 内置组件化后丢失 ⇒ 只剩 95% 白叠玻璃层 ≈ rgb(248,249,251),与卡头 #f7f8f9 只差 1~2 级,整卡糊成一色。
|
||||||
|
background: #fff;
|
||||||
|
// 内置组件的浮层(如课表班级切换下拉)需溢出卡片,故不裁切;
|
||||||
|
// 卡片圆角由自身背景维持,疑似溢出子元素(iframe)自带底部圆角。
|
||||||
|
overflow: visible;
|
||||||
margin: 18px 14px 0 14px;
|
margin: 18px 14px 0 14px;
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
.no-data {
|
||||||
|
min-height: 90px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #b9c2cc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-app-header {
|
.widget-app-header {
|
||||||
@@ -45,25 +75,56 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e9e9e9;
|
border-bottom: 1px solid #e9e9e9;
|
||||||
|
// 卡头浅灰底(原版观感;合成色曾随壁纸漂移,这里显式钉死)
|
||||||
|
background: #f7f8f9;
|
||||||
|
// .widget-item 已改 overflow:visible,卡头底色的顶部圆角需自补
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
color: #344449;
|
color: #344449;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
svg {
|
svg {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
// 右区:内置组件 header 控件挂载点 + 关闭按钮(.widget-app-header 是 space-between,右区整体靠右)
|
||||||
|
.widget-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.widget-header-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
iframe {
|
iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
|
// .widget-item 已改为 overflow:visible,页面片段的底部圆角自行补齐
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拖拽态翻白(沿用旧版白色语义:面板与 header 一并提亮,模糊略加强)。
|
||||||
|
// ⚠️ 该元素同时挂着 .widget-wrap,--wg-fg* 是给深色玻璃配的白字,
|
||||||
|
// 翻白后必须同步反转为深色,否则重演「白底压白字 / 浅灰图标不可见」。
|
||||||
&__drag {
|
&__drag {
|
||||||
|
--wg-glass-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
--wg-glass-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--wg-glass-shadow: 0 14px 44px rgba(15, 23, 42, 0.2);
|
||||||
|
--wg-fg: #1f2d3d;
|
||||||
|
--wg-fg-dim: rgba(31, 45, 61, 0.62);
|
||||||
|
--wg-fg-shadow: none;
|
||||||
|
|
||||||
|
background: var(--wg-glass-bg);
|
||||||
|
backdrop-filter: blur(22px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||||
|
box-shadow: var(--wg-glass-shadow);
|
||||||
|
|
||||||
.widget-header {
|
.widget-header {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +134,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #828c96;
|
color: var(--wg-fg);
|
||||||
|
text-shadow: var(--wg-fg-shadow);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--wg-fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -24,10 +24,25 @@
|
|||||||
box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
|
box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .logo-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
& .logo-box {
|
& .logo-box {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .logo-text {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #252b35;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
& .operate-container {
|
& .operate-container {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
& > li {
|
& > li {
|
||||||
@@ -58,96 +73,157 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
}
|
||||||
height: 36px;
|
}
|
||||||
margin-right: 2px;
|
|
||||||
|
/* ===== Header 搜索命令面板(2026-10-08 定稿 B 方案)=====
|
||||||
|
⚠️ 必须放在顶层(不可嵌进 .default-header 内):面板由 <Teleport to="body"> 渲染到 body 下,
|
||||||
|
若嵌在 .default-header 内会生成 `.default-header .app-cmdk-layer`,匹配不到 body 下的元素。
|
||||||
|
类名统一 app-cmdk- 前缀(本文件是全局样式,非 scoped)。 */
|
||||||
|
.app-cmdk-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s linear;
|
||||||
|
|
||||||
|
&.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-cmdk-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(18, 26, 40, 0.34);
|
||||||
|
backdrop-filter: blur(10px) saturate(125%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(125%);
|
||||||
|
}
|
||||||
|
.app-cmdk-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 78px;
|
||||||
|
width: min(620px, calc(100% - 48px));
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 34px;
|
border-radius: 16px;
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 26px 68px rgba(18, 30, 54, 0.3), 0 2px 8px rgba(18, 30, 54, 0.1);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -14px) scale(0.965);
|
||||||
|
transition: opacity 0.26s linear, transform 0.48s cubic-bezier(0.2, 0.9, 0.24, 1);
|
||||||
|
|
||||||
|
.app-cmdk-layer.is-open & {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-cmdk-hd {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
user-select: none;
|
padding: 0 18px;
|
||||||
|
height: 56px;
|
||||||
|
border-bottom: 1px solid #f0f3f8;
|
||||||
|
color: #98a2b0;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
border: none;
|
border: 0;
|
||||||
margin-left: 15px;
|
color: #252b35;
|
||||||
width: 210px;
|
font-size: 15px;
|
||||||
}
|
background: none;
|
||||||
.icon {
|
&::placeholder {
|
||||||
margin: 0 8px;
|
color: #aab3c0;
|
||||||
// animation: icon-turn 0.3s linear;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@keyframes fade-animation {
|
.app-cmdk-kbd {
|
||||||
0% {
|
font-size: 11px;
|
||||||
display: none;
|
color: #8b95a3;
|
||||||
}
|
background: #f2f5fa;
|
||||||
|
border: 1px solid #e4e9f2;
|
||||||
75% {
|
border-radius: 5px;
|
||||||
display: none;
|
padding: 2px 6px;
|
||||||
}
|
font-family: ui-monospace, Menlo, monospace;
|
||||||
100% {
|
}
|
||||||
|
.app-cmdk-list {
|
||||||
|
padding: 8px;
|
||||||
|
max-height: min(58vh, 440px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.app-cmdk-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
}
|
gap: 12px;
|
||||||
.fade1-enter-active,
|
padding: 9px 12px;
|
||||||
.fade1-enter-to {
|
border-radius: 10px;
|
||||||
animation: fade-animation 0.5s;
|
cursor: pointer;
|
||||||
}
|
opacity: 0;
|
||||||
|
transform: translateY(9px);
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.42s cubic-bezier(0.4, 0, 0.2, 1), background 0.16s;
|
||||||
|
|
||||||
.fade1-leave-from,
|
.app-cmdk-layer.is-open & {
|
||||||
.fade1-leave-active {
|
opacity: 1;
|
||||||
display: none !important;
|
transform: none;
|
||||||
|
/* --i 封顶 20:应用多时(100+)末尾条目延迟不再线性膨胀到数秒,滚动空白窗上限 ≈0.84s */
|
||||||
|
transition-delay: calc(0.14s + min(var(--i), 20) * 0.035s),
|
||||||
|
calc(0.14s + min(var(--i), 20) * 0.035s), 0s;
|
||||||
}
|
}
|
||||||
|
&.is-hl,
|
||||||
@keyframes search-animation {
|
&:hover {
|
||||||
0% {
|
background: #f3f6fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-cmdk-list.is-instant .app-cmdk-item {
|
||||||
|
transition-duration: 0.15s;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
.app-cmdk-img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-cmdk-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #252b35;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 应用类型:右侧灰字(来源 BoaAppType.name,与 /apps 分组标题同源) */
|
||||||
|
.app-cmdk-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #98a2b0;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.app-cmdk-empty {
|
||||||
display: none;
|
display: none;
|
||||||
width: 0;
|
padding: 28px 18px 32px;
|
||||||
}
|
text-align: center;
|
||||||
20% {
|
color: #a8b1be;
|
||||||
display: none;
|
font-size: 13px;
|
||||||
width: 0;
|
&.is-show {
|
||||||
}
|
|
||||||
21% {
|
|
||||||
width: 0;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
100% {
|
}
|
||||||
width: 210px;
|
.app-cmdk-ft {
|
||||||
}
|
display: flex;
|
||||||
}
|
gap: 16px;
|
||||||
|
padding: 10px 18px;
|
||||||
@keyframes icon-animation {
|
border-top: 1px solid #f0f3f8;
|
||||||
0% {
|
font-size: 11.5px;
|
||||||
transform: rotate(0deg);
|
color: #98a2b0;
|
||||||
}
|
background: #fbfcfe;
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-enter-active {
|
|
||||||
& input {
|
|
||||||
animation: search-animation 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
& .icon {
|
|
||||||
animation: icon-animation 0.4s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-leave-active {
|
|
||||||
& input {
|
|
||||||
animation: search-animation 0.35s ease-out reverse;
|
|
||||||
}
|
|
||||||
& .icon {
|
|
||||||
animation: icon-animation 0.4s reverse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-info {
|
.role-info {
|
||||||
|
|||||||
@@ -152,6 +152,33 @@ div.dib {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分段 Tab(设置中心「桌面应用」角色分段 / 系统设置「应用管理」角色分段共用,替代 iView Tabs 观感)
|
||||||
|
.seg-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: #f0f2f6;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
.sg {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #7a8088;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
&:hover {
|
||||||
|
color: #5979ea;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #5979ea;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// .chart-box {
|
// .chart-box {
|
||||||
// height: 50px;
|
// height: 50px;
|
||||||
// width: 100%;
|
// width: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<header :class="headerClass">
|
<header :class="headerClass">
|
||||||
|
<div class="logo-wrap">
|
||||||
<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="" />
|
||||||
|
<span v-if="logoText" class="logo-text">{{ logoText }}</span>
|
||||||
|
</div>
|
||||||
<ul class="operate-container fix">
|
<ul class="operate-container fix">
|
||||||
<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">
|
||||||
@@ -8,17 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="isSearchIconShow">
|
<li v-if="isSearchIconShow">
|
||||||
<Transition name="fade1" :duration="{ enter: 1000, leave: 0 }">
|
<div class="icon-box" v-wave @click="openSearch">
|
||||||
<div v-show="!isSearching" @click="isSearching = true" class="icon-box" v-wave>
|
|
||||||
<SvgIcon name="header-1" color="#333333" />
|
<SvgIcon name="header-1" color="#333333" />
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
<Transition name="search" :duration="{ enter: 300, leave: 300 }">
|
|
||||||
<div v-show="isSearching" class="search-box">
|
|
||||||
<input ref="refSearchInput" v-model="searchAppText" type="text" placeholder="请输入应用名称搜索" />
|
|
||||||
<SvgIcon @click="isSearching = false" class="icon" name="header-1" size="20" color="#c4c4c4" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</li>
|
</li>
|
||||||
<!-- 人像图标 =唯一入口(2026-10-08整合):铃铛与齿轮两枚图标下线,
|
<!-- 人像图标 =唯一入口(2026-10-08整合):铃铛与齿轮两枚图标下线,
|
||||||
消息中心仍走左侧菜单 /msg(LeftMenu 保留未读角标);
|
消息中心仍走左侧菜单 /msg(LeftMenu 保留未读角标);
|
||||||
@@ -84,6 +79,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- WS 实时推送卡片(design/03 .ws-toast),Teleport 已落到 body -->
|
||||||
|
<WsToastLayer ref="wsToastRef" />
|
||||||
|
|
||||||
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
|
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
|
||||||
:transfer="false">
|
:transfer="false">
|
||||||
<vuescroll>
|
<vuescroll>
|
||||||
@@ -178,33 +176,83 @@
|
|||||||
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
|
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
|
||||||
<MsgApprovalModal ref="refMsgApproval" />
|
<MsgApprovalModal ref="refMsgApproval" />
|
||||||
|
|
||||||
<StatsBarModal ref="refStatsBarModal" />
|
|
||||||
<TeacherCopilotModal ref="refTeacherCopilotModal" />
|
<TeacherCopilotModal ref="refTeacherCopilotModal" />
|
||||||
|
|
||||||
|
<!-- 搜索命令面板(2026-10-08 定稿 B 方案):必须 Teleport 到 body ——
|
||||||
|
.default-header 有 position:relative + z-index:10,会创建层叠上下文,
|
||||||
|
面板写在 Header 内部时无论 z-index 多高,对外只表现为 10,会被左侧菜单与内容区盖住 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="app-cmdk-layer" :class="{ 'is-open': isSearching }">
|
||||||
|
<div class="app-cmdk-mask" @click="closeSearch"></div>
|
||||||
|
<div class="app-cmdk-panel">
|
||||||
|
<div class="app-cmdk-hd">
|
||||||
|
<SvgIcon name="header-1" size="19" color="#98a2b0" />
|
||||||
|
<input
|
||||||
|
ref="refSearchInput"
|
||||||
|
v-model="searchAppText"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入应用名以搜索"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<span class="app-cmdk-kbd">Esc</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-cmdk-list" :class="{ 'is-instant': !cmdkStagger }" ref="refCmdkList">
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in cmdkResult"
|
||||||
|
:key="item.id ?? i"
|
||||||
|
class="app-cmdk-item"
|
||||||
|
:class="{ 'is-hl': i === cmdkIndex }"
|
||||||
|
:style="{ '--i': i }"
|
||||||
|
@click="pickApp(item)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
v-if="vectorIconOf(item)"
|
||||||
|
:name="vectorIconOf(item)"
|
||||||
|
scene="tile"
|
||||||
|
:tint="item.iconColor"
|
||||||
|
/>
|
||||||
|
<img v-else :src="$utils.appIconPath(item.icon)" class="app-cmdk-img" alt="" />
|
||||||
|
<span class="app-cmdk-name">{{ item.name }}</span>
|
||||||
|
<span class="app-cmdk-type" v-if="item.appTypeName">{{ item.appTypeName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-cmdk-empty" :class="{ 'is-show': !cmdkResult.length }">
|
||||||
|
{{ store.appListLoading ? "正在加载应用…" : "未找到匹配的应用" }}
|
||||||
|
</div>
|
||||||
|
<div class="app-cmdk-ft">
|
||||||
|
<span>↑↓ 选择</span><span>↵ 打开</span><span>Esc 关闭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import vuescroll from "vuescroll";
|
import vuescroll from "vuescroll";
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue";
|
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch, nextTick } from "vue";
|
||||||
import { useDefaultStore } from "@/store";
|
import { useDefaultStore } from "@/store";
|
||||||
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 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";
|
||||||
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
||||||
import { Notice } from "view-ui-plus";
|
import WsToastLayer from "@/components/WsToastLayer.vue";
|
||||||
import { IframePageParmas, RoleInfo } from "@/types";
|
import { IframePageParmas, RoleInfo } from "@/types";
|
||||||
import { isEqual, orderBy } from "lodash";
|
import { isEqual, orderBy } from "lodash";
|
||||||
import UserSettingApi from "@/api/UserSettingApi";
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
import mittBus from "@/utils/mitt";
|
import mittBus from "@/utils/mitt";
|
||||||
import MenuBarApi from "@/api/MenuBarApi";
|
import MenuBarApi from "@/api/MenuBarApi";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import AppIcon from "@/components/AppIcon.vue";
|
||||||
|
import { appiconKeyOf } from "@/constants/appIconRules";
|
||||||
|
import { openAppPage } from "@/utils/bschLinkPage";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const wsToastRef = ref<InstanceType<typeof WsToastLayer>>();
|
||||||
const store = useDefaultStore();
|
const store = useDefaultStore();
|
||||||
const refMsgApproval = ref<any>();
|
const refMsgApproval = ref<any>();
|
||||||
const { $conf } = useCurrentInstance();
|
const { $conf } = useCurrentInstance();
|
||||||
@@ -214,7 +262,6 @@ const baseUrl = computed(() => $conf.fileHost);
|
|||||||
* 但摘要拉取与 WS 推送仍需保留 —— /msg 页左栏与 LeftMenu 角标与本store 同源 */
|
* 但摘要拉取与 WS 推送仍需保留 —— /msg 页左栏与 LeftMenu 角标与本store 同源 */
|
||||||
|
|
||||||
const refSearchInput = ref<any>();
|
const refSearchInput = ref<any>();
|
||||||
const refStatsBarModal = ref<any>();
|
|
||||||
const refTeacherCopilotModal = ref<any>();
|
const refTeacherCopilotModal = ref<any>();
|
||||||
|
|
||||||
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
||||||
@@ -225,6 +272,8 @@ const logoUrl = computed(() => {
|
|||||||
const sysLogo = logoList.value.find(x => x.type == 0);
|
const sysLogo = logoList.value.find(x => x.type == 0);
|
||||||
return sysLogo ? sysLogo.url : "";
|
return sysLogo ? sysLogo.url : "";
|
||||||
});
|
});
|
||||||
|
/** LOGO 文字(平台/学校名称,2026-10-09):存 sch_settings.logo_text(级联读),与 LOGO 图独立 */
|
||||||
|
const logoText = ref("");
|
||||||
const isUserCardShow = ref(false),
|
const isUserCardShow = ref(false),
|
||||||
isWallpaperShow = ref(false),
|
isWallpaperShow = ref(false),
|
||||||
isLogoShow = ref(false),
|
isLogoShow = ref(false),
|
||||||
@@ -293,25 +342,33 @@ async function connectMsgWs() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WS 帧按 appKey 匹配应用名(store 应用列表);查无回退类型标签(缺失降级,不报错) */
|
|
||||||
function frameAppName(frame: any) {
|
|
||||||
const appKey = frame && frame.appKey;
|
|
||||||
if (appKey) {
|
|
||||||
const hit = store.getFlattenAppList.find((x: any) => x.appKey == appKey || x.appCode == appKey);
|
|
||||||
if (hit && hit.name) return hit.name;
|
|
||||||
}
|
|
||||||
return msgTypeLabel(frame ? frame.msgType : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onNewMessage(frame: any) {
|
function onNewMessage(frame: any) {
|
||||||
|
// 他校新消息对当前学校口径无信息量(不计未读、不打扰、也不触发 /msg 页重拉)
|
||||||
|
if (!bellVisibleForCurrentSchool(frame)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
store.incUnread();
|
store.incUnread();
|
||||||
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
|
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
|
||||||
mittBus.emit("msgCenter:newMessage", frame);
|
mittBus.emit("msgCenter:newMessage", frame);
|
||||||
Notice.info({
|
// design/03 .ws-toast 实时卡片(2026-10-09):类型色条+图标章+进度条自动消退,点击进 /msg
|
||||||
title: `【${frameAppName(frame)}】新消息`,
|
wsToastRef.value?.push(frame);
|
||||||
desc: frame.content || "",
|
}
|
||||||
duration: 5,
|
|
||||||
});
|
/**
|
||||||
|
* 铃铛按校过滤(消息按学校隔离 批次4):口径必须与批次3 服务端列表一致——
|
||||||
|
* 平台级消息(帧无 schId)任何身份都提醒;平台/超管身份(getCurSchId 非正数,含 schId=0
|
||||||
|
* 被 store 归一成 -1 的形态)看全部,一律提醒;其余只认当前激活身份学校。
|
||||||
|
*/
|
||||||
|
function bellVisibleForCurrentSchool(frame: any): boolean {
|
||||||
|
const frameSchId = frame ? frame.schId : null;
|
||||||
|
if (frameSchId === null || frameSchId === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const curSchId = store.getCurSchId;
|
||||||
|
if (!curSchId || curSchId <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Number(frameSchId) === Number(curSchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMsgWs() {
|
function closeMsgWs() {
|
||||||
@@ -360,22 +417,109 @@ onMounted(async () => {
|
|||||||
isSearching.value = false;
|
isSearching.value = false;
|
||||||
searchAppText.value = null;
|
searchAppText.value = null;
|
||||||
});
|
});
|
||||||
|
window.addEventListener("keydown", onCmdkKeydown);
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
mittBus.off("searchReset");
|
mittBus.off("searchReset");
|
||||||
|
mittBus.off("switchRole", onSwitchRoleRefresh);
|
||||||
|
window.removeEventListener("keydown", onCmdkKeydown);
|
||||||
closeMsgWs();
|
closeMsgWs();
|
||||||
});
|
});
|
||||||
watch(isSearching, val => {
|
|
||||||
if (val) {
|
// 切换身份后角标随新身份学校口径刷新(emit 在 savUserRole 落库之后,服务端 identity/current 已是新校)
|
||||||
setTimeout(() => refSearchInput.value?.focus(), 300);
|
function onSwitchRoleRefresh() {
|
||||||
} else {
|
loadSummary();
|
||||||
// searchAppText.value = null;
|
}
|
||||||
|
mittBus.on("switchRole", onSwitchRoleRefresh);
|
||||||
|
// 面板已是模态框,输入只驱动面板自身过滤;
|
||||||
|
// 不再 emit("searchChange") —— 背景应用网格保持原样(2026-10-08 用户修订 1)
|
||||||
|
watch(searchAppText, () => {
|
||||||
|
cmdkStagger.value = false; // 输入过滤时不重播逐条入场
|
||||||
|
cmdkIndex.value = 0; // 过滤使结果集变化,高亮回归首项(否则旧索引落空/错位,Enter 哑炮)
|
||||||
|
});
|
||||||
|
// ===== 搜索命令面板(2026-10-08 定稿 B 方案)=====
|
||||||
|
const refCmdkList = ref<any>();
|
||||||
|
const cmdkIndex = ref(0);
|
||||||
|
const cmdkStagger = ref(true); // 仅首次打开做逐条入场,输入过滤时不重播
|
||||||
|
|
||||||
|
/** 与 AppList.vue / index.vue / SearchPanel.vue 同口径的矢量渲染回退链 */
|
||||||
|
function vectorIconOf(item: any): string {
|
||||||
|
if (item?.iconKey) return item.iconKey;
|
||||||
|
if (item?.icon) return "";
|
||||||
|
return appiconKeyOf(item?.name || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果取自 store.curUserAppList(写入方 = AppList.reloadAppData)。
|
||||||
|
* 它是 BoaAppType 分组结构 [{ id, name, apps }],name 就是应用类型名;
|
||||||
|
* 展开时把类型名挂到每条上供右侧灰字展示。
|
||||||
|
* 注意别用 store.getFlattenAppList —— 它是纯 flatten,会把类型名丢掉 */
|
||||||
|
const cmdkResult = computed(() => {
|
||||||
|
const q = (searchAppText.value || "").trim();
|
||||||
|
const out: any[] = [];
|
||||||
|
(store.curUserAppList || []).forEach((group: any) => {
|
||||||
|
(group.apps || []).forEach((app: any) => {
|
||||||
|
if (!q || (app.name || "").indexOf(q) !== -1) {
|
||||||
|
out.push({ ...app, appTypeName: group.name });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(searchAppText, val => {
|
function openSearch() {
|
||||||
mittBus.emit("searchChange", val);
|
cmdkStagger.value = true;
|
||||||
});
|
cmdkIndex.value = 0;
|
||||||
|
searchAppText.value = null;
|
||||||
|
isSearching.value = true;
|
||||||
|
nextTick(() => refSearchInput.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
isSearching.value = false;
|
||||||
|
searchAppText.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlight(step: number) {
|
||||||
|
const len = cmdkResult.value.length;
|
||||||
|
if (!len) return;
|
||||||
|
cmdkIndex.value = (cmdkIndex.value + step + len) % len;
|
||||||
|
nextTick(() => {
|
||||||
|
const el = refCmdkList.value?.children?.[cmdkIndex.value];
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickApp(item: any) {
|
||||||
|
closeSearch();
|
||||||
|
openAppPage(item, $conf.ifameAppIdArr); // 与点击应用图标同一条路径
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCmdkKeydown(ev: KeyboardEvent) {
|
||||||
|
if (!isSearching.value) return; // 面板未开:绝不拦截全局按键
|
||||||
|
// IME 组合态(拼音候选未上屏)不响应导航/确认:keydown 的 Enter/↑↓ 属输入法内部按键,
|
||||||
|
// 不拦截防误开应用、不 preventDefault 防干扰候选词选择(keyCode 229 为旧式组合态标记)
|
||||||
|
if (ev.isComposing || ev.keyCode === 229) return;
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === "ArrowDown") {
|
||||||
|
ev.preventDefault();
|
||||||
|
moveHighlight(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === "ArrowUp") {
|
||||||
|
ev.preventDefault();
|
||||||
|
moveHighlight(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
const it = cmdkResult.value[cmdkIndex.value];
|
||||||
|
if (it) pickApp(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getIsCheckRoles(item: RoleInfo) {
|
function getIsCheckRoles(item: RoleInfo) {
|
||||||
return isEqual(item, store.curRoleInfo);
|
return isEqual(item, store.curRoleInfo);
|
||||||
}
|
}
|
||||||
@@ -392,11 +536,6 @@ async function changeRole(role: RoleInfo) {
|
|||||||
await loadLogoList();
|
await loadLogoList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
|
|
||||||
function msgTypeLabel(msgType: number | null) {
|
|
||||||
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
|
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
|
||||||
await getUserWallpaperList();
|
await getUserWallpaperList();
|
||||||
}
|
}
|
||||||
@@ -423,11 +562,6 @@ function linkSidebarSetting() {
|
|||||||
router.push("/settings?pane=sidebar");
|
router.push("/settings?pane=sidebar");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function settingsStats() {
|
|
||||||
isUserCardShow.value = false;
|
|
||||||
refStatsBarModal.value.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settingsTeacherCopilot() {
|
async function settingsTeacherCopilot() {
|
||||||
isUserCardShow.value = false;
|
isUserCardShow.value = false;
|
||||||
refTeacherCopilotModal.value.show();
|
refTeacherCopilotModal.value.show();
|
||||||
@@ -446,11 +580,20 @@ async function onClickWallpaper(url: string) {
|
|||||||
async function onWallpaperSelect(item: any) {
|
async function onWallpaperSelect(item: any) {
|
||||||
await UserSettingApi.deleteWallById(item.id);
|
await UserSettingApi.deleteWallById(item.id);
|
||||||
await getUserWallpaperList();
|
await getUserWallpaperList();
|
||||||
|
// 删除的是当前生效壁纸时重拉用户设置(后端兜底接管),桌面即时回落不白板
|
||||||
|
if (store.getCurWallPaperUrl === item.url) {
|
||||||
|
const userSetting = await UserSettingApi.getUserSettings();
|
||||||
|
if (userSetting) store.setCurUserSettingsInfo(userSetting);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogoList() {
|
async function loadLogoList() {
|
||||||
const list = await UserSettingApi.getLogoList();
|
const list = await UserSettingApi.getLogoList();
|
||||||
logoList.value = list || [];
|
logoList.value = list || [];
|
||||||
|
// LOGO 文字与图并行加载(失败静默——无文字仅显示图,不影响 header)
|
||||||
|
BaseApi.getSchLogoText()
|
||||||
|
.then(res => (logoText.value = res?.logoText || ""))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logoSuccess(response: any, file: any, fileListt: any) {
|
async function logoSuccess(response: any, file: any, fileListt: any) {
|
||||||
@@ -470,11 +613,6 @@ async function toggleWidgetShow() {
|
|||||||
await UserSettingApi.toggleWidgetShow(id);
|
await UserSettingApi.toggleWidgetShow(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkSysSetting() {
|
|
||||||
isUserCardShow.value = false;
|
|
||||||
router.push("/syssetting");
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkBackendManage() {
|
function linkBackendManage() {
|
||||||
isUserCardShow.value = false;
|
isUserCardShow.value = false;
|
||||||
const url = `${$conf.baseHost}/userbase/front/index.html`;
|
const url = `${$conf.baseHost}/userbase/front/index.html`;
|
||||||
@@ -487,8 +625,6 @@ async function linkLogoSetting() {
|
|||||||
isLogoShow.value = true;
|
isLogoShow.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSeachBox() { }
|
|
||||||
|
|
||||||
/** 卡片菜单:个人信息 → 设置中心 profile pane(与通知端/用户中心同一地址)。
|
/** 卡片菜单:个人信息 → 设置中心 profile pane(与通知端/用户中心同一地址)。
|
||||||
* 注意:mittBus 的 showUserInfoModal 事件仍由 AppHeader/BschIframe 使用,此处不再触发 */
|
* 注意:mittBus 的 showUserInfoModal 事件仍由 AppHeader/BschIframe 使用,此处不再触发 */
|
||||||
function showUserInfo() {
|
function showUserInfo() {
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Modal
|
|
||||||
v-model="isTableModalShow"
|
|
||||||
class-name="menuwapper-modal"
|
|
||||||
width="1160"
|
|
||||||
:mask="true"
|
|
||||||
title="统计模块设置"
|
|
||||||
:transfer="false"
|
|
||||||
>
|
|
||||||
<div class="fix mb-10">
|
|
||||||
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
|
|
||||||
新建统计模块
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table
|
|
||||||
:columns="menuBarColumns"
|
|
||||||
:height="360"
|
|
||||||
:loading="tableLoading"
|
|
||||||
:data="tableList"
|
|
||||||
class="link-table"
|
|
||||||
no-data-text="暂无数据"
|
|
||||||
>
|
|
||||||
<template #icon="{ row }">
|
|
||||||
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
|
|
||||||
</template>
|
|
||||||
<template #isShow="{ row }">
|
|
||||||
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #roleIds="{ row }">
|
|
||||||
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
|
|
||||||
</template>
|
|
||||||
<!-- <template #orderNum="{ row }">
|
|
||||||
<InputNumber :model-value="row.orderNum" :min="1" />
|
|
||||||
</template> -->
|
|
||||||
</Table>
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
|
||||||
v-model="isEditModalShow"
|
|
||||||
scrollable
|
|
||||||
width="550"
|
|
||||||
:title="(!formData.id ? '添加' : '编辑') + '统计模块'"
|
|
||||||
:transfer="true"
|
|
||||||
>
|
|
||||||
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
|
|
||||||
<Form-Item label="权限角色:" class="fix" prop="roleIds">
|
|
||||||
<Checkbox
|
|
||||||
class="fl"
|
|
||||||
:indeterminate="indeterminate"
|
|
||||||
:model-value="compCheckAll"
|
|
||||||
@click.prevent="handleCheckAll"
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</Checkbox>
|
|
||||||
<CheckboxGroup v-model="formData.roleIds">
|
|
||||||
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
|
|
||||||
</CheckboxGroup>
|
|
||||||
</Form-Item>
|
|
||||||
<Form-Item label="名称:" prop="name">
|
|
||||||
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
|
|
||||||
</Form-Item>
|
|
||||||
<Form-Item label="网址:" prop="url">
|
|
||||||
<Input v-model="formData.url" placeholder="请输入链接" />
|
|
||||||
</Form-Item>
|
|
||||||
<Form-Item label="图标:" prop="icon">
|
|
||||||
<Upload
|
|
||||||
:on-success="uploadIconSuccess"
|
|
||||||
:show-upload-list="false"
|
|
||||||
:action="$conf.serviceHost + '/api/iconUpload'"
|
|
||||||
:format="['jpg', 'jpeg', 'png', 'gif']"
|
|
||||||
accept="image/gif,image/jpeg,image/jpg,image/png"
|
|
||||||
:with-credentials="true"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
>
|
|
||||||
<div v-if="!formData.icon" class="icon-upload-wrap">
|
|
||||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
|
||||||
</div>
|
|
||||||
<div class="icon-preview-wrap" v-else>
|
|
||||||
<SvgIcon
|
|
||||||
@click.stop="formData.icon = null"
|
|
||||||
class="cloase-btn"
|
|
||||||
name="widget-close"
|
|
||||||
size="18"
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
<img :src="$conf.serviceHost + formData.icon" alt="" />
|
|
||||||
</div>
|
|
||||||
</Upload>
|
|
||||||
</Form-Item>
|
|
||||||
|
|
||||||
<Form-Item label="排序号:" prop="orderNum">
|
|
||||||
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
|
|
||||||
</Form-Item>
|
|
||||||
<Form-Item label="打开方式:" prop="linkOpenType">
|
|
||||||
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
|
|
||||||
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
</Form-Item>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
|
|
||||||
<Button @click="isEditModalShow = false">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import StatsBarApi from "@/api/StatsBarApi";
|
|
||||||
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeEnum, RoleTypeDict } from "@/types";
|
|
||||||
import Utils from "@/utils";
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import { Message } from "view-ui-plus";
|
|
||||||
import { ref, reactive, computed } from "vue";
|
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
|
||||||
import { BaseDict } from "@/types/Basics";
|
|
||||||
import { find } from "lodash";
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
id: string | null;
|
|
||||||
roleIds: number[];
|
|
||||||
name: string | null;
|
|
||||||
url: string | null;
|
|
||||||
linkOpenType: number;
|
|
||||||
icon: string | null;
|
|
||||||
orderNum: number | null;
|
|
||||||
isShow: boolean;
|
|
||||||
isEdit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 formData 的默认值
|
|
||||||
const defaultFormData: FormData = {
|
|
||||||
id: null,
|
|
||||||
roleIds: [], // 添加 roleIds 属性
|
|
||||||
name: "",
|
|
||||||
url: "",
|
|
||||||
linkOpenType: 0,
|
|
||||||
icon: "",
|
|
||||||
orderNum: null,
|
|
||||||
isShow: true,
|
|
||||||
isEdit: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { $conf } = useCurrentInstance();
|
|
||||||
|
|
||||||
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
|
||||||
|
|
||||||
const refEditForm = ref<any>(null);
|
|
||||||
|
|
||||||
const isTableModalShow = ref(false),
|
|
||||||
isEditModalShow = ref(false),
|
|
||||||
tableLoading = ref(false),
|
|
||||||
editLoading = ref(false),
|
|
||||||
tableList = ref<any[]>([]),
|
|
||||||
formData = reactive(defaultFormData);
|
|
||||||
|
|
||||||
const ruleValidate = ref({
|
|
||||||
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
|
|
||||||
name: { required: true, message: "请输入名称", trigger: "blur" },
|
|
||||||
url: { required: true, message: "请输入网址", trigger: "blur" },
|
|
||||||
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
|
|
||||||
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
|
|
||||||
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
|
|
||||||
|
|
||||||
const menuBarColumns = ref([
|
|
||||||
{
|
|
||||||
title: "名称",
|
|
||||||
key: "name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "图标",
|
|
||||||
slot: "icon",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "角色",
|
|
||||||
width: 150,
|
|
||||||
slot: "roleIds",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "地址",
|
|
||||||
key: "url",
|
|
||||||
width: 150,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "打开方式",
|
|
||||||
key: "linkType",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "是否显示",
|
|
||||||
width: 100,
|
|
||||||
align: "center",
|
|
||||||
slot: "isShow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "排序号",
|
|
||||||
key: "orderNum",
|
|
||||||
align: "center",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
width: 120,
|
|
||||||
align: "center",
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
const disabled = !params.row.isEdit,
|
|
||||||
id = params.row.id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
|
|
||||||
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function show() {
|
|
||||||
isTableModalShow.value = true;
|
|
||||||
await getMenuBarList();
|
|
||||||
console.log("show");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMenuBarList() {
|
|
||||||
tableLoading.value = true;
|
|
||||||
|
|
||||||
const list = await StatsBarApi.getStatsBarSchList();
|
|
||||||
list.forEach(element => {
|
|
||||||
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
|
|
||||||
});
|
|
||||||
tableList.value = list;
|
|
||||||
tableLoading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCheckAll() {
|
|
||||||
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
|
|
||||||
}
|
|
||||||
function uploadIconSuccess(response: any, file: any, fileListt: any) {
|
|
||||||
if (response.code == 0) formData.icon = response.data.url;
|
|
||||||
else Message.error("上传错误!");
|
|
||||||
// console.log(response, file, fileListt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onEditFormShow(id?: number) {
|
|
||||||
await refEditForm.value?.resetFields();
|
|
||||||
isEditModalShow.value = false;
|
|
||||||
if (!id) {
|
|
||||||
formData.id = null;
|
|
||||||
formData.linkOpenType = LinkOpenTypeEnum.Open;
|
|
||||||
} else {
|
|
||||||
let find = tableList.value.find(x => x.id == id);
|
|
||||||
Object.assign(formData, find);
|
|
||||||
}
|
|
||||||
isEditModalShow.value = true;
|
|
||||||
}
|
|
||||||
async function onDeleteMenu(id: number) {
|
|
||||||
await MyConfirm({
|
|
||||||
title: "删除",
|
|
||||||
msg: "确定删除该统计模块吗?",
|
|
||||||
info: "删除后无法恢复!",
|
|
||||||
async onOk() {
|
|
||||||
await StatsBarApi.deleteStatsBar(id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await getMenuBarList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onEditSave() {
|
|
||||||
let validate: boolean = await refEditForm.value?.validate();
|
|
||||||
if (!validate) return false;
|
|
||||||
editLoading.value = true;
|
|
||||||
try {
|
|
||||||
await StatsBarApi.saveStatsBar(formData);
|
|
||||||
Message.success("操作成功!");
|
|
||||||
await getMenuBarList();
|
|
||||||
isEditModalShow.value = false;
|
|
||||||
} catch (error) {
|
|
||||||
Message.success("操作失败!");
|
|
||||||
} finally {
|
|
||||||
editLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onMenuBarShowChange(id: number) {
|
|
||||||
StatsBarApi.toggleStatsBarShow(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.icon-box {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
import { computed } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -22,7 +22,16 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
const style = computed(() => resolveAvatarStyle());
|
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||||
|
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||||
|
const appearanceTick = ref(0);
|
||||||
|
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||||
|
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
const style = computed(() => {
|
||||||
|
appearanceTick.value;
|
||||||
|
return resolveAvatarStyle();
|
||||||
|
});
|
||||||
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
const showText = computed(() => style.value === "text" && !!text.value);
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="ws-toast-layer">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="ws-toast" :class="[t.cls, { out: t.leaving }]"
|
||||||
|
@click="goMsgCenter(t)">
|
||||||
|
<div class="wt">
|
||||||
|
<div class="ic">{{ t.ic }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="nm">{{ t.name }}</div>
|
||||||
|
<div class="tm">{{ t.tm }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="x" @click.stop="dismiss(t)">✕</div>
|
||||||
|
</div>
|
||||||
|
<div class="wc"><span class="tt">{{ t.title }}</span>{{ t.content }}</div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="progress"><i :style="{ animationDuration: DURATION_MS + 'ms' }"></i></div>
|
||||||
|
<div class="go" :class="{ gold: t.cls === 't2' }">{{ t.go }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onUnmounted, reactive } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WS 实时推送卡片(design/03 .ws-toast 落地,2026-10-09):替代 view-ui-plus Notice.info 临时卡片。
|
||||||
|
* 类型样式:0系统通知 t1(主色)🔔 / 1业务提醒 t2(金色)📋 / 2审批待办 t3(红)⏳;
|
||||||
|
* 8s 进度条自动消退,点击卡片或「去处理/查看」跳 /msg(列表口径由服务端按校过滤,本卡片只做提醒不承载数据权威)。
|
||||||
|
* 审批直批按钮(设计稿 approve-row)本期不做,待办仍进消息中心处理。
|
||||||
|
* 颜色用 var(--x, 回落值):App 目前未定义 --color-* 主题变量,回落取设计稿 :root 同值,未来接主题无需改这里。
|
||||||
|
*/
|
||||||
|
const DURATION_MS = 8000;
|
||||||
|
const LEAVE_MS = 280;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
cls: string;
|
||||||
|
ic: string;
|
||||||
|
name: string;
|
||||||
|
tm: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
go: string;
|
||||||
|
leaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = reactive<ToastItem[]>([]);
|
||||||
|
let seq = 0;
|
||||||
|
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/** 类型 → 卡片样式/图标/脚标文案(msgType 值域与 WsPushPayload 一致:0/1/2,缺省按系统通知) */
|
||||||
|
function typeSpec(msgType: number | null) {
|
||||||
|
switch (msgType) {
|
||||||
|
case 1:
|
||||||
|
return { cls: "t2", ic: "📋", go: "去处理 →" };
|
||||||
|
case 2:
|
||||||
|
return { cls: "t3", ic: "⏳", go: "查看详情 →" };
|
||||||
|
default:
|
||||||
|
return { cls: "t1", ic: "🔔", go: "查看全部消息 →" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeText(frame: any) {
|
||||||
|
const sendTime: string = frame?.sendTime || "";
|
||||||
|
const hhmm = sendTime.length >= 16 ? sendTime.slice(11, 16) : "";
|
||||||
|
return `新消息 · ${hhmm || "刚刚"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header onNewMessage 入口:帧已过按校过滤,这里只负责呈现 */
|
||||||
|
function push(frame: any) {
|
||||||
|
const spec = typeSpec(frame ? frame.msgType : null);
|
||||||
|
const item: ToastItem = {
|
||||||
|
id: ++seq,
|
||||||
|
cls: spec.cls,
|
||||||
|
ic: spec.ic,
|
||||||
|
name: { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[frame?.msgType as 0 | 1 | 2] || "系统通知",
|
||||||
|
tm: timeText(frame),
|
||||||
|
title: frame?.title || "",
|
||||||
|
content: frame?.content || "",
|
||||||
|
go: spec.go,
|
||||||
|
leaving: false,
|
||||||
|
};
|
||||||
|
// 最多同屏 4 张:再来的挤掉最老一张,防长消息风暴糊屏
|
||||||
|
while (toasts.length >= 4) {
|
||||||
|
drop(toasts[0].id);
|
||||||
|
}
|
||||||
|
toasts.push(item);
|
||||||
|
timers.set(item.id, setTimeout(() => dismiss(item), DURATION_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(item: ToastItem) {
|
||||||
|
if (item.leaving) return;
|
||||||
|
item.leaving = true;
|
||||||
|
const t = timers.get(item.id);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
timers.delete(item.id);
|
||||||
|
setTimeout(() => drop(item.id), LEAVE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(id: number) {
|
||||||
|
const idx = toasts.findIndex(t => t.id === id);
|
||||||
|
if (idx >= 0) toasts.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goMsgCenter(item: ToastItem) {
|
||||||
|
dismiss(item);
|
||||||
|
router.push("/msg");
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
timers.forEach(t => clearTimeout(t));
|
||||||
|
timers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ push });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ws-toast-layer {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
top: calc(var(--headerH, 70px) + 8px);
|
||||||
|
z-index: 1500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 352px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(255, 255, 255, .97);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: var(--radius-m, 10px);
|
||||||
|
box-shadow: 0 12px 32px rgba(30, 40, 60, .18), 0 2px 6px rgba(30, 40, 60, .08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .8);
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 4px solid var(--color-primary, #5979ea);
|
||||||
|
animation: wsIn .42s cubic-bezier(.2, .9, .3, 1.2);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.t1 { border-left-color: var(--color-primary, #5979ea); }
|
||||||
|
&.t2 { border-left-color: var(--color-warning, #e9b954); }
|
||||||
|
&.t3 { border-left-color: var(--color-error, #e8684a); }
|
||||||
|
|
||||||
|
&.out { animation: wsOut .28s ease forwards; }
|
||||||
|
|
||||||
|
.wt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 15px 9px;
|
||||||
|
|
||||||
|
.ic {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.nm {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #252b33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-weak, #b3b3b3);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
color: #c0c6ce;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-page, #f3f3f3);
|
||||||
|
color: #7a8088;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.t1 .ic { background: var(--color-primary-weak, #eef2ff); color: var(--color-primary, #5979ea); }
|
||||||
|
&.t2 .ic { background: #fdf4e3; color: #c8922a; }
|
||||||
|
&.t3 .ic { background: #fdecea; color: var(--color-error, #e8684a); }
|
||||||
|
|
||||||
|
.wc {
|
||||||
|
padding: 0 15px 12px 55px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a6068;
|
||||||
|
line-height: 1.65;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tt {
|
||||||
|
color: #2c3138;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 15px;
|
||||||
|
background: rgba(249, 250, 252, .9);
|
||||||
|
border-top: 1px solid var(--border-light, #eef0f3);
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: #e6e9ee;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary, #5979ea);
|
||||||
|
border-radius: 2px;
|
||||||
|
animation-name: wsProg;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.go {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-primary, #5979ea);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.gold { color: #c8922a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wsIn {
|
||||||
|
from { opacity: 0; transform: translateX(36px) scale(.96); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wsOut {
|
||||||
|
to { opacity: 0; transform: translateX(36px) scale(.96); margin-bottom: -100px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wsProg {
|
||||||
|
from { width: 100%; }
|
||||||
|
to { width: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wg-evaluate" ref="refRoot">
|
||||||
|
<Skeleton
|
||||||
|
v-if="loading"
|
||||||
|
style="padding: 15px"
|
||||||
|
:loading="true"
|
||||||
|
:title="false"
|
||||||
|
:paragraph="{ rows: 4 }"
|
||||||
|
animated
|
||||||
|
/>
|
||||||
|
<ul v-else-if="list.length > 0" class="tpk-ul">
|
||||||
|
<li v-for="item in list" :key="item.id">
|
||||||
|
<a :href="courseDetailUrl(item.id)" target="_blank" rel="noopener noreferrer" :title="item.title">
|
||||||
|
<span class="title">{{ item.title }}</span>
|
||||||
|
<span class="sub">{{ item.lectureteaName }} | {{ item.subName }}</span>
|
||||||
|
<span class="date">{{ formatDate(item.lectureTime) }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="no-data">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
|
||||||
|
defineProps<{ widgetId: number }>();
|
||||||
|
const emit = defineEmits(["loaded"]);
|
||||||
|
|
||||||
|
const refRoot = ref<HTMLElement | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const list = ref<any[]>([]);
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (window.ResizeObserver && refRoot.value) {
|
||||||
|
observer = new ResizeObserver(reportHeight);
|
||||||
|
observer.observe(refRoot.value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data: any = await UserSettingApi.getWidgetData("evaluate", "page=0&count=5");
|
||||||
|
list.value = (data && data.list) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] evaluate 取数失败", e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
reportHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function reportHeight() {
|
||||||
|
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(lectureTime: string) {
|
||||||
|
return (lectureTime || "").slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转链接由前端拼(口径同旧 evaluate_index.js getLinkUrl),候选池不再下发任何 html 地址 */
|
||||||
|
function courseDetailUrl(id: number) {
|
||||||
|
return window.location.origin + "/EvaluateCourese/front/#/courseDetail?id=" + id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.wg-evaluate {
|
||||||
|
padding: 6px 0 4px;
|
||||||
|
}
|
||||||
|
.tpk-ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
li {
|
||||||
|
margin: 0 15px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 26px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 26px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 26px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
min-height: 90px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #b9c2cc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wg-syllabus" ref="refRoot">
|
||||||
|
<Skeleton
|
||||||
|
v-if="loading"
|
||||||
|
style="padding: 15px"
|
||||||
|
:loading="true"
|
||||||
|
:title="false"
|
||||||
|
:paragraph="{ rows: 4 }"
|
||||||
|
animated
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 班级切换器:挂进卡片 header(index.vue 埋的 wg-hdr-<id> 锚点);多班(教师任教/班主任、校领导全校)才显示 -->
|
||||||
|
<Teleport v-if="classes.length > 1" :to="'#wg-hdr-' + widgetId">
|
||||||
|
<div class="cls-dd" :class="{ open: ddOpen }">
|
||||||
|
<button type="button" class="cls-trigger" @click.stop="ddOpen = !ddOpen">
|
||||||
|
<span class="cls-name">{{ classLabel(currentClass) }}</span>
|
||||||
|
<svg class="caret" viewBox="0 0 1024 1024" width="10" height="10" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M512 704 128 320l64-64 320 320 320-320 64 64z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-show="ddOpen" class="cls-panel">
|
||||||
|
<div
|
||||||
|
v-for="(cls, index) in classes"
|
||||||
|
:key="cls.classId"
|
||||||
|
class="cls-item"
|
||||||
|
:class="{ on: index === clsIdx }"
|
||||||
|
@click="pickClass(index)"
|
||||||
|
>
|
||||||
|
{{ classLabel(cls) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div v-if="slots.length > 0" class="table-wrap">
|
||||||
|
<!-- 超过 7 节(表头 44 + 7×44 = 352px)纵向滚动,表头 sticky 常驻 -->
|
||||||
|
<div class="tt-scroll">
|
||||||
|
<table class="tt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>节</th>
|
||||||
|
<th v-for="day in dayCols" :key="day.index">{{ day.name }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="slot in slots" :key="slot.slotNo">
|
||||||
|
<td class="sec">{{ cnNum(slot.slotNo) }}</td>
|
||||||
|
<td v-for="day in dayCols" :key="day.index">
|
||||||
|
<span class="sub" :title="subjectOf(slot, day.index)">{{ subjectOf(slot, day.index) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空态:模板内联 SVG,不依赖外部图片 / CSS 背景 -->
|
||||||
|
<div v-else class="wg-empty">
|
||||||
|
<svg width="112" height="112" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="#F2F3F5" />
|
||||||
|
<rect y="182.438" width="210" height="27.5625" fill="#F7F8FA" />
|
||||||
|
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC" />
|
||||||
|
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="#F2F3F5" />
|
||||||
|
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="#fff" />
|
||||||
|
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="#EAEDF1" />
|
||||||
|
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA" />
|
||||||
|
</svg>
|
||||||
|
<span class="txt">暂无课表数据</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
|
||||||
|
defineProps<{ widgetId: number }>();
|
||||||
|
const emit = defineEmits(["loaded"]);
|
||||||
|
|
||||||
|
/** 节次中文数字(下标 0 = 第 1 节) */
|
||||||
|
const CN_NUM = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
|
||||||
|
/** 星期名(下标 0~6 = 周一~周日,对齐 slots[].subjects 下标口径) */
|
||||||
|
const WEEK_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
|
||||||
|
|
||||||
|
const refRoot = ref<HTMLElement | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
/** oapi data[]:每项一个班级(classId/className/gradeName/timetable.slots…) */
|
||||||
|
const classes = ref<any[]>([]);
|
||||||
|
const clsIdx = ref(0);
|
||||||
|
const ddOpen = ref(false);
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const currentClass = computed<any>(() => classes.value[clsIdx.value] || {});
|
||||||
|
|
||||||
|
/** 节次行:按 slotNo 升序(按接口实际返回渲染) */
|
||||||
|
const slots = computed<any[]>(() => {
|
||||||
|
const list = currentClass.value && currentClass.value.timetable && currentClass.value.timetable.slots;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return [...list].sort((a, b) => ((a && a.slotNo) || 0) - ((b && b.slotNo) || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 星期列:恒 周一~周五;周六/周日有课才追加 */
|
||||||
|
const dayCols = computed(() => {
|
||||||
|
const cols = [0, 1, 2, 3, 4];
|
||||||
|
if (slots.value.some(slot => subjectOf(slot, 5))) cols.push(5);
|
||||||
|
if (slots.value.some(slot => subjectOf(slot, 6))) cols.push(6);
|
||||||
|
return cols.map(index => ({ index, name: WEEK_NAMES[index] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 班级展示名 = 年级 + 班级(如「高一 1班」);无年级时退化为班级名 */
|
||||||
|
function classLabel(cls: any): string {
|
||||||
|
if (!cls) return "";
|
||||||
|
const grade = cls.gradeName ? String(cls.gradeName) : "";
|
||||||
|
const name = cls.className ? String(cls.className) : "";
|
||||||
|
return grade ? `${grade} ${name}` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectOf(slot: any, index: number): string {
|
||||||
|
const arr = slot && slot.subjects;
|
||||||
|
if (!Array.isArray(arr) || !arr[index]) return "";
|
||||||
|
return String(arr[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cnNum(slotNo: number): string {
|
||||||
|
return CN_NUM[slotNo - 1] || String(slotNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickClass(index: number) {
|
||||||
|
clsIdx.value = index;
|
||||||
|
ddOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
ddOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
document.addEventListener("click", closeDropdown);
|
||||||
|
if (window.ResizeObserver && refRoot.value) {
|
||||||
|
observer = new ResizeObserver(reportHeight);
|
||||||
|
observer.observe(refRoot.value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data: any = await UserSettingApi.getWidgetData("syllabus", "");
|
||||||
|
classes.value = Array.isArray(data) ? data : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] syllabus 取数失败", e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
reportHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("click", closeDropdown);
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function reportHeight() {
|
||||||
|
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.wg-syllabus {
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 班级切换器(下拉;渲染在卡片 header 内,见 index.vue 的 wg-hdr-<id> 锚点) ---------- */
|
||||||
|
.cls-dd {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cls-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
// header 是 700 粗体,切换器属次级信息 → 复位常规字重
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #344449;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eef1f5;
|
||||||
|
}
|
||||||
|
.caret {
|
||||||
|
color: #98a2b0;
|
||||||
|
transition: transform 0.18s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cls-dd.open .caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.cls-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 132px;
|
||||||
|
max-height: 264px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e7eaee;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.14);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.cls-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #344449;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f4f7ff;
|
||||||
|
}
|
||||||
|
&.on {
|
||||||
|
color: var(--color-primary, #5979ea);
|
||||||
|
background: var(--color-primary-weak, #eef2ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 课表 ---------- */
|
||||||
|
/* ⚠️ 表格类名必须用私有名(tt):用 my-table 会命中全局 componets/_my_table.scss,
|
||||||
|
带进 #c7c7c7 竖线与 th 灰底 ⇒「竖线明显 / 表头与卡头糊成一片 / 横线被压得看不见」 */
|
||||||
|
.table-wrap {
|
||||||
|
padding: 8px 8px 10px;
|
||||||
|
}
|
||||||
|
/* 最多露出 7 节课(表头 44 + 7×44 = 352),再多则纵向滚动,表头 sticky 常驻 */
|
||||||
|
.tt-scroll {
|
||||||
|
max-height: 352px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #d5dbe3 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #d5dbe3;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tt {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
th {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #344449;
|
||||||
|
text-align: center;
|
||||||
|
// reset(meyerweb) 给 th/td 统一设了 vertical-align: baseline ⇒ 必须显式复位,
|
||||||
|
// 否则表头文字按基线对齐、偏上,视觉上像"贴住卡片 header"
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 0;
|
||||||
|
// sticky + border-collapse 时 cell 的 border 会随滚动丢失 ⇒ 用 inset box-shadow 画分隔线
|
||||||
|
box-shadow: inset 0 -1px 0 #e9edf1;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
width: 46px;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
height: 44px;
|
||||||
|
border-bottom: 1px solid #eef1f4;
|
||||||
|
padding: 4px 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
td.sec {
|
||||||
|
color: #6e7c87;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 科目文本:最多 2 行,超出以省略号收尾,title 悬停可看全文 */
|
||||||
|
.sub {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 空态(模板内联 SVG,不依赖外部图片 / CSS 背景) ---------- */
|
||||||
|
.wg-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 16px 0 20px;
|
||||||
|
|
||||||
|
.txt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #98a2b0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wg-weekplan" ref="refRoot">
|
||||||
|
<!-- ④ 「查看概览」:Teleport 进卡片 header 右侧(index.vue 埋的 #wg-hdr-<id> 锚点,
|
||||||
|
与课程表班级切换器同一机制);a 标签直跳子应用,加载中/空态都在位,不随周数据闪灭 -->
|
||||||
|
<Teleport v-if="overviewUrl" :to="'#wg-hdr-' + widgetId">
|
||||||
|
<a class="ov-link" :href="overviewUrl" target="_blank" rel="noopener">查看概览</a>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 加载态:结构化骨架。各段与真实内容同构同高(标题 40 + 七日条 63 + 列表 171 + 备注区 66.6),
|
||||||
|
换周不再「先塌再弹」;方案 B 下加载/空周/有备注周三态恒等 ≈364.6px -->
|
||||||
|
<div v-if="loading" class="wk-skeleton" aria-hidden="true">
|
||||||
|
<div class="sk-title">
|
||||||
|
<i class="bar" style="width: 46%"></i>
|
||||||
|
<span class="sk-pager"><i></i><i></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="sk-days">
|
||||||
|
<div class="sd on"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
<div class="sd"><span class="w"></span><b><i></i></b></div>
|
||||||
|
</div>
|
||||||
|
<div class="sk-list">
|
||||||
|
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
|
||||||
|
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
|
||||||
|
<div class="sr"><div class="l"><span class="c"></span><span class="t"></span></div><div class="m"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sk-remark-slot"><i class="b1"></i><i class="b2"></i></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 门控用 hasWeek(有 title 的周对象)而非 hasData:空周(hasData=false)
|
||||||
|
也必须保留标题行 + 翻页箭头,否则用户翻进空周后没有出口 -->
|
||||||
|
<template v-else-if="hasWeek">
|
||||||
|
<div class="wk-title">
|
||||||
|
<!-- 显示用简称(日期区间 + 第N周);接口全称(如「学校工作安排」)留在 title 里 hover 可见 -->
|
||||||
|
<span :title="titleFull">{{ titleStr }}</span>
|
||||||
|
<!-- 翻页:边界由响应 minWeek/maxWeek 驱动(缺省按 [0,0] 禁用两侧);越界 oapi 返回 code:2,前端先拦 -->
|
||||||
|
<span class="pager">
|
||||||
|
<i :class="{ disabled: !canPrev }" @click="onPrev">‹</i>
|
||||||
|
<i :class="{ disabled: !canNext }" @click="onNext">›</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="days.length > 0" class="wk-days">
|
||||||
|
<div
|
||||||
|
v-for="(day, index) in days"
|
||||||
|
:key="day.date"
|
||||||
|
:class="['d', { 'is-on': index === currentIndex, has: hasWork(index), rest: day.dayType === 1, holiday: day.dayType === 2 }]"
|
||||||
|
@click="onDayClick(index)"
|
||||||
|
>
|
||||||
|
<span>{{ day.weekday }}</span>
|
||||||
|
<b>{{ dayText(day) }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表区固定高度(= 3 条 × --wk-row):超出滚动;空态(休/节假日/无安排)同高,卡片高度不跳 -->
|
||||||
|
<div class="wk-list-area">
|
||||||
|
<ul v-if="currentList.length > 0" class="wk-list">
|
||||||
|
<li v-for="(item, index) in currentList" :key="index">
|
||||||
|
<!-- 标题独占一行(右侧不再被时间占宽,长标题多显示约 8 字) -->
|
||||||
|
<span class="content" :title="item.contents">{{ item.contents }}</span>
|
||||||
|
<!-- 信息行恒渲染:地点 / 负责人 / 出席对象全空时落「-」,不留白(条目保持等高);
|
||||||
|
时间移到本行最右端;无时间不留位 -->
|
||||||
|
<div class="meta">
|
||||||
|
<span class="meta-txt">{{ metaOf(item) || "-" }}</span>
|
||||||
|
<span v-if="item.times" class="time" :class="{ 'is-cjk': isCjk(item.times) }">{{ item.times }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="no-data">{{ currentDayLabel }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备注区恒占位(min-height = 2 行折叠高 58.59px):无备注只留白、不出灰盒,
|
||||||
|
与骨架/空周/有备注周同高 ⇒ 任意切换零高度变化(方案 B)。
|
||||||
|
默认 2 行截断,溢出才给「展开」(折叠态按钮贴第二行末尾) -->
|
||||||
|
<div class="wk-remark-slot">
|
||||||
|
<div v-if="remarkText" class="wk-remark">
|
||||||
|
<div ref="refRemark" class="rk-txt" :class="{ expanded: remarkExpanded }">{{ remarkText }}</div>
|
||||||
|
<span v-if="remarkOverflow" class="rk-more" @click="toggleRemark">{{ remarkExpanded ? "收起" : "展开" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 空态:模板内联 SVG,不依赖外部图片 / CSS 背景 -->
|
||||||
|
<div v-else class="wg-empty">
|
||||||
|
<svg width="112" height="112" viewBox="0 0 210 242" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect opacity="0.01" x="0.5" y="0.5" width="209" height="209" stroke="#DCDEE0" stroke-dasharray="5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M21 69.5625H47.25V171.938H24.9375H21H2.625V95.8125H21V69.5625Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M105 36.5217C104.368 36.671 103.709 36.75 103.031 36.75C98.3196 36.75 94.5 32.9304 94.5 28.2188C94.5 23.721 97.9807 20.0361 102.395 19.7108C102.382 19.486 102.375 19.2594 102.375 19.0312C102.375 12.8698 107.37 7.875 113.531 7.875C118.555 7.875 122.803 11.1953 124.201 15.7611C124.362 15.7537 124.524 15.75 124.688 15.75C130.486 15.75 135.188 20.451 135.188 26.25C135.188 32.049 130.486 36.75 124.688 36.75C124.243 36.75 123.805 36.7224 123.375 36.6688V36.75H105V36.5217Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M20.8304 43.8601C20.5247 43.9314 20.2058 43.969 19.878 43.969C17.5982 43.969 15.75 42.1461 15.75 39.8973C15.75 37.7506 17.4342 35.9919 19.5704 35.8367C19.5638 35.7293 19.5605 35.6211 19.5605 35.5121C19.5605 32.5714 21.9774 30.1875 24.9587 30.1875C27.3894 30.1875 29.4449 31.7722 30.1212 33.9512C30.1994 33.9477 30.2779 33.946 30.3569 33.946C33.1629 33.946 35.4376 36.1896 35.4376 38.9573C35.4376 41.725 33.1629 43.9687 30.3569 43.9687C30.1417 43.9687 29.9297 43.9555 29.7215 43.9299V43.9688H20.8304V43.8601Z" fill="#F2F3F5" />
|
||||||
|
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M190.616 19.6875V38.0625H202.125V139.609H161.438V38.0625V19.6875H190.616Z" fill="#F2F3F5" />
|
||||||
|
<rect y="182.438" width="210" height="27.5625" fill="#F7F8FA" />
|
||||||
|
<rect opacity="0.6" x="107.625" y="82.6875" width="47.25" height="69.5625" rx="2" fill="#DCDCDC" />
|
||||||
|
<rect x="57.75" y="65.625" width="84" height="86.625" rx="2" fill="#F2F3F5" />
|
||||||
|
<rect x="65.625" y="73.5" width="68.25" height="72.1875" rx="1" fill="#fff" />
|
||||||
|
<rect x="77.4375" y="87.9375" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="77.4375" y="106.312" width="44.625" height="7.875" fill="#EAEDF1" />
|
||||||
|
<rect x="77.4375" y="124.688" width="44.625" height="7.875" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="47.25" y="145.688" width="115.5" height="36.75" rx="1" fill="#EAEDF1" />
|
||||||
|
<rect x="85.3125" y="160.125" width="39.375" height="7.875" rx="1" fill="#F7F8FA" />
|
||||||
|
</svg>
|
||||||
|
<span class="txt">暂无数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
|
||||||
|
defineProps<{ widgetId: number; overviewUrl?: string }>();
|
||||||
|
const emit = defineEmits(["loaded"]);
|
||||||
|
|
||||||
|
const refRoot = ref<HTMLElement | null>(null);
|
||||||
|
const refRemark = ref<HTMLElement | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
/**
|
||||||
|
* 接口 data:单个周对象(title/year/term/week/monday/days[]/remark/hasData/minWeek/maxWeek),非数组。
|
||||||
|
* 命名警示:形参 week(偏移量)曾与 ref week(响应体)同名遮蔽,严格模式下 `week.value=` 抛
|
||||||
|
* TypeError 且 catch 内再抛 ⇒ loading 永驻(v1 骨架卡死根因)⇒ ref 恒用 weekData,禁止再命名 week。
|
||||||
|
*/
|
||||||
|
const weekData = ref<any>({});
|
||||||
|
/** week 偏移:0=本周、-1=上一周、+1=下周(与数据字段 data.week 绝对周次同名不同义) */
|
||||||
|
const weekOffset = ref(0);
|
||||||
|
/** 合法偏移范围:每次响应刷新;老版本服务缺省时按 [0,0] 处理(宁可不翻,也不打越界 code:2) */
|
||||||
|
const minWeek = ref(0);
|
||||||
|
const maxWeek = ref(0);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
/** 备注展开态;remarkOverflow 只在折叠态测量(溢出才显示按钮) */
|
||||||
|
const remarkExpanded = ref(false);
|
||||||
|
const remarkOverflow = ref(false);
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const days = computed<any[]>(() => (Array.isArray(weekData.value.days) ? weekData.value.days : []));
|
||||||
|
/** 标题简称:日期区间 + 第N周(接口 title 往往是「××学校工作安排」这类长名,放到 hover) */
|
||||||
|
const titleStr = computed<string>(() => {
|
||||||
|
const list = days.value;
|
||||||
|
const week = Number(weekData.value.week);
|
||||||
|
const weekPart = Number.isFinite(week) && week > 0 ? `第${week}周` : "";
|
||||||
|
if (list.length >= 2) {
|
||||||
|
const range = `${monthDay(list[0])}~${monthDay(list[list.length - 1])}`;
|
||||||
|
return weekPart ? `${range} ${weekPart}` : range;
|
||||||
|
}
|
||||||
|
return weekPart || weekData.value.title || "";
|
||||||
|
});
|
||||||
|
/** hover 全称:接口 title,与简称相同则不挂(避免重复提示) */
|
||||||
|
const titleFull = computed<string>(() => {
|
||||||
|
const full = weekData.value.title || "";
|
||||||
|
return full === titleStr.value ? "" : full;
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* 有周对象 = titleStr 非空(含 hasData=false 的空周:后端仍回 title/days,只是 items 全空)。
|
||||||
|
* 翻页箭头必须随之保留,避免「翻进空周 → 箭头消失 → 无法返回」的死角;
|
||||||
|
* 真正无周对象(oapi 降级返回 {})才走大图标空态。
|
||||||
|
*/
|
||||||
|
const hasWeek = computed(() => titleStr.value !== "");
|
||||||
|
// trim:备注尾部换行/空格会多出一个空行,让「展开」在只有 2 行内容时也误判溢出
|
||||||
|
const remarkText = computed<string>(() => String(weekData.value.remark || "").trim());
|
||||||
|
const canPrev = computed(() => weekOffset.value > minWeek.value);
|
||||||
|
const canNext = computed(() => weekOffset.value < maxWeek.value);
|
||||||
|
|
||||||
|
const currentList = computed<any[]>(() => {
|
||||||
|
const day = days.value[currentIndex.value];
|
||||||
|
return day && Array.isArray(day.items) ? day.items : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 当天无安排时的占位文案:优先接口 dayLabel(节日名/无/休),否则兜「暂无工作安排」 */
|
||||||
|
const currentDayLabel = computed<string>(() => {
|
||||||
|
const day = days.value[currentIndex.value];
|
||||||
|
return (day && day.dayLabel) || "暂无工作安排";
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (window.ResizeObserver && refRoot.value) {
|
||||||
|
observer = new ResizeObserver(reportHeight);
|
||||||
|
observer.observe(refRoot.value);
|
||||||
|
}
|
||||||
|
await loadWeek(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadWeek(week: number) {
|
||||||
|
weekOffset.value = week;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data: any = await UserSettingApi.getWidgetData("weekplan", "week=" + week);
|
||||||
|
weekData.value = data && typeof data === "object" ? data : {};
|
||||||
|
// 合法偏移范围:老版本服务不返回时按 0 处理(两侧禁用)
|
||||||
|
minWeek.value = Number.isFinite(Number(weekData.value.minWeek)) ? Number(weekData.value.minWeek) : 0;
|
||||||
|
maxWeek.value = Number.isFinite(Number(weekData.value.maxWeek)) ? Number(weekData.value.maxWeek) : 0;
|
||||||
|
if (week === 0) locateToday();
|
||||||
|
else currentIndex.value = 0; // 非本周默认落周一,不沿用「今天」下标
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] weekplan 取数失败", e);
|
||||||
|
weekData.value = {};
|
||||||
|
} finally {
|
||||||
|
// finally 收尾:即使 catch 内再抛,loading 也必须落 false(v1 骨架卡死的直接机制)
|
||||||
|
loading.value = false;
|
||||||
|
// 换周/换数据后重置备注折叠态并重测溢出
|
||||||
|
remarkExpanded.value = false;
|
||||||
|
remarkOverflow.value = false;
|
||||||
|
nextTick(measureRemark);
|
||||||
|
reportHeight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日定位:优先按 date 匹配今日 ISO(防跨周边界偏差),匹配不到退星期推算 */
|
||||||
|
function locateToday() {
|
||||||
|
const list = days.value;
|
||||||
|
if (list.length === 0) {
|
||||||
|
currentIndex.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
const hit = list.findIndex(d => d && d.date === iso);
|
||||||
|
currentIndex.value = hit >= 0 ? hit : (now.getDay() + 6) % 7;
|
||||||
|
if (currentIndex.value >= list.length) currentIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return n < 10 ? "0" + n : "" + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPrev() {
|
||||||
|
if (!canPrev.value) return;
|
||||||
|
loadWeek(weekOffset.value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNext() {
|
||||||
|
if (!canNext.value) return;
|
||||||
|
loadWeek(weekOffset.value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDayClick(index: number) {
|
||||||
|
currentIndex.value = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWork(index: number) {
|
||||||
|
const day = days.value[index];
|
||||||
|
return !!day && Array.isArray(day.items) && day.items.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期条只显示「日」,不带月份:优先 date(yyyy-MM-dd),退化到 dateText 尾部数字 */
|
||||||
|
function dayText(day: any) {
|
||||||
|
if (!day) return "";
|
||||||
|
const date = typeof day.date === "string" ? day.date : "";
|
||||||
|
if (date.length >= 10) return date.slice(8, 10);
|
||||||
|
const text = String(day.dateText || "");
|
||||||
|
const hit = text.match(/(\d{1,2})\s*$/);
|
||||||
|
return hit ? hit[1].padStart(2, "0") : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M月D日(标题区间用;月/日去前导零) */
|
||||||
|
function monthDay(day: any) {
|
||||||
|
const date = day && typeof day.date === "string" ? day.date : "";
|
||||||
|
if (date.length >= 10) {
|
||||||
|
return `${Number(date.slice(5, 7))}月${Number(date.slice(8, 10))}日`;
|
||||||
|
}
|
||||||
|
return String((day && day.dateText) || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 次要信息行:地点 / 负责人 / 参与人员;三者全空时落「-」,不留白(条目保持等高) */
|
||||||
|
function metaOf(item: any) {
|
||||||
|
if (!item) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (item.place) parts.push(item.place);
|
||||||
|
if (item.principal) parts.push(item.principal);
|
||||||
|
if (item.participant) parts.push(item.participant);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧时间文案是否含汉字(如「全天」):同字号下汉字字面高 ≈1.29 × 数字,降一档字号(.is-cjk) */
|
||||||
|
function isCjk(value: any) {
|
||||||
|
return /[\u4e00-\u9fa5]/.test(String(value || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRemark() {
|
||||||
|
remarkExpanded.value = !remarkExpanded.value;
|
||||||
|
if (!remarkExpanded.value) nextTick(measureRemark);
|
||||||
|
reportHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 备注溢出判定:折叠态下内容高度超过可视高度才算溢出(展开态保持按钮可见,不重测) */
|
||||||
|
function measureRemark() {
|
||||||
|
const el = refRemark.value;
|
||||||
|
if (!el || remarkExpanded.value) return;
|
||||||
|
remarkOverflow.value = el.scrollHeight > el.clientHeight + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportHeight() {
|
||||||
|
if (refRoot.value) emit("loaded", refRoot.value.offsetHeight);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* 主色(2026-10-16 指定):今日方块 / 七日条圆点 / 「查看概览」/「展开」/ 今日星期 */
|
||||||
|
$wk-primary: #5979ea;
|
||||||
|
$wk-primary-dark: #4864d8;
|
||||||
|
|
||||||
|
.wg-weekplan {
|
||||||
|
/* 一条安排的高度:内容行(15px × 1.5 = 22.5) + 信息行(12.5px × 1.5 = 18.75)
|
||||||
|
+ 上下 padding 12 + 分隔线 1 ≈ 56.25px ⇒ 取 57px 留余量。
|
||||||
|
有/无「地点·负责人·参与人」的条目都占这个高度,标题行始终对齐。 */
|
||||||
|
--wk-row: 57px;
|
||||||
|
/* 结构化骨架的灰条颜色 */
|
||||||
|
--wk-skel: rgba(190, 190, 190, 0.35);
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
}
|
||||||
|
.wk-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 15px;
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #344449;
|
||||||
|
> span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
i {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6e7c87;
|
||||||
|
/* 定尺寸 + flex 双向居中:行盒(24px)里字形贴基线,直接给背景会把字形顶偏 */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.16s, color 0.16s;
|
||||||
|
user-select: none;
|
||||||
|
&.disabled {
|
||||||
|
color: #d6dbe1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
&:not(.disabled):hover {
|
||||||
|
background: #eef1f5;
|
||||||
|
color: #344449;
|
||||||
|
}
|
||||||
|
&:not(.disabled):active {
|
||||||
|
background: #e2e7ee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wk-days {
|
||||||
|
display: flex;
|
||||||
|
/* 下边距 6→8:删除「日常详情」行后补回原视觉间距(原来 6 与 tips 的 8 塌缩取 max=8) */
|
||||||
|
margin: 0 15px 8px;
|
||||||
|
gap: 4px;
|
||||||
|
.d {
|
||||||
|
flex: 1 1 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6e7c87;
|
||||||
|
border-radius: 4px;
|
||||||
|
// 底部留出圆点位置:整周无安排时也占位,日期条高度恒定
|
||||||
|
padding: 4px 0 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
// 星期数:块级行盒 + 下留 3px —— 把今日方块与圆点整体让下去,数字本身位置不动
|
||||||
|
// (方块 28px 上下居中于数字,不加留白时上沿贴「星期数」4.1px、下沿贴圆点 1.5px)
|
||||||
|
// ⚠️ 必须用 padding-bottom 而非 margin-bottom:后者会与 b 的 margin-top 折叠
|
||||||
|
//(max(正) + min(负)),今日格的 −1px 与其余格的 2px 会走出不同结果 ⇒ 今日数字偏低 1.75px
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
line-height: 21px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
b {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #344449;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
// 圆点改锚数字块(不再锚格子):格子被今日格 28px 方块拉高时圆点不再跟着沉
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
// 圆点绝对定位在数字块下方 6px(bottom:-11px ⇒ 点中心 = b 底边 + 9,与留白一起把圆点让到方块下方)
|
||||||
|
b::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -11px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $wk-primary;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&.has b::after {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
// 休息日(周六 / 周日)与法定节假日:日期数字同档弱化灰(节假日不再标红)
|
||||||
|
&.rest b,
|
||||||
|
&.holiday b {
|
||||||
|
color: #98a2b0;
|
||||||
|
}
|
||||||
|
// 今日:蓝底白字方块(置最后,压过 rest / holiday 的日期字色)
|
||||||
|
// 28px 行盒会把数字中心相对 22.5px 行盒下移 5.5px(canvas 字体度量实测墨迹中心
|
||||||
|
// 11.25 → 14),margin-top 取 −1px(精确 −0.75 取整)补回,数字与其余 6 天同基线
|
||||||
|
&.is-on {
|
||||||
|
color: $wk-primary;
|
||||||
|
b {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: -1px auto 0;
|
||||||
|
line-height: 28px;
|
||||||
|
background: $wk-primary;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
// 方块比数字行盒高 5.5px,圆点随之低 2.5px ⇒ 上收 2.5px,七格圆点同线
|
||||||
|
b::after {
|
||||||
|
bottom: -8.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ④ 「查看概览」:渲染在卡片 header 右侧(Teleport 到 index.vue 的 #wg-hdr-<id> 锚点),
|
||||||
|
样式复刻课程表班级切换器 .cls-trigger 一族;a 标签直跳、无浮层 */
|
||||||
|
.ov-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #344449;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
&:hover {
|
||||||
|
background: #eef1f5;
|
||||||
|
color: $wk-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 列表区:固定高度 = 3 条(3 × --wk-row,即 171px);多条滚动、空态占位同高,
|
||||||
|
避免切到「休/节假日」时卡片高度跳变 */
|
||||||
|
.wk-list-area {
|
||||||
|
height: calc(var(--wk-row) * 3);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #d3d8de;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wk-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
li {
|
||||||
|
margin: 0 15px;
|
||||||
|
// 条目等高:有/无「地点·负责人·参与人」都占一行高度(无信息落「-」占位,不留白),
|
||||||
|
// 保证标题行纵向对齐;overflow 兜住固定高度可能带来的亚像素裁切
|
||||||
|
height: var(--wk-row);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f2f4f7;
|
||||||
|
color: #344449;
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 标题独占一行(时间已移到信息行):14px,右侧不再被时间占宽
|
||||||
|
.content {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #344449;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
// 信息行:地点 / 负责人 / 参与人 在左,时间贴最右端;无时间时信息文本照常占满
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #98a2b0;
|
||||||
|
}
|
||||||
|
.meta-txt {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
// ⚠️ 必须显式 min-width:0:flex 项默认 min-width:auto,遇 nowrap 长文本
|
||||||
|
// 会以 max-content 为下限而不收缩 ⇒ text-overflow 永不触发
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #6e7c87;
|
||||||
|
// 汉字型时间(全天/放假)降一档:同字号下汉字字面高 ≈1.29 × 数字(15px 实测 18px vs 14px)
|
||||||
|
&.is-cjk {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ---------- 加载态:结构化骨架(与内容逐段同构同高,换周不塌不跳) ----------
|
||||||
|
标题 40 + 七日条 63 + 列表 171 + 备注区 66.59 + 根部 padding 16 ≈ 364.6px,
|
||||||
|
与「空周内容」「有备注周内容」严格相等(方案 B)。注意根部勿再加 padding */
|
||||||
|
.wk-skeleton {
|
||||||
|
.sk-title {
|
||||||
|
margin: 0 15px;
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
.bar {
|
||||||
|
height: 16px;
|
||||||
|
width: 46%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
// font-size 16 撑出与真实 pager 相同的 24px 行盒(标题段总高 40 的关键)
|
||||||
|
.sk-pager {
|
||||||
|
font-size: 16px;
|
||||||
|
i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sk-days {
|
||||||
|
display: flex;
|
||||||
|
margin: 0 15px 8px;
|
||||||
|
gap: 4px;
|
||||||
|
.sd {
|
||||||
|
flex: 1 1 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 0 11px;
|
||||||
|
// 星期条:外框 24px 与真实星期数同高(21px 行盒 + 3px 下留白),条本身 10px 居上
|
||||||
|
// ⚠️ 内层条的定位必须用父级 padding-top,不能用子元素 margin-top:
|
||||||
|
// 父级无 padding/border 时子元素上边距会「塌」到父级外面,凭空多出 5.5px 高度
|
||||||
|
.w {
|
||||||
|
display: block;
|
||||||
|
width: 14px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-top: 5.5px;
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-top: 2px;
|
||||||
|
i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 今日格:与真实 .d.is-on b 同几何(28px 方块 + margin-top −1)
|
||||||
|
&.on b {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: -1px auto 0;
|
||||||
|
i {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $wk-primary;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sk-list {
|
||||||
|
height: calc(var(--wk-row) * 3);
|
||||||
|
.sr {
|
||||||
|
height: var(--wk-row);
|
||||||
|
margin: 0 15px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f2f4f7;
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.l {
|
||||||
|
height: 21px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
.c {
|
||||||
|
height: 14px;
|
||||||
|
width: 58%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
.t {
|
||||||
|
height: 14px;
|
||||||
|
width: 64px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m {
|
||||||
|
margin-top: 2px;
|
||||||
|
height: 10px;
|
||||||
|
width: 32%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 备注区占位:与 .wk-remark-slot 同高(min-height 58.59,无 padding,盒尺寸与全局 border-box 无关)
|
||||||
|
.sk-remark-slot {
|
||||||
|
margin: 8px 15px 0;
|
||||||
|
min-height: 58.59px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
i {
|
||||||
|
display: block;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wk-skel);
|
||||||
|
&.b1 {
|
||||||
|
width: 78%;
|
||||||
|
margin: 11px 10px 0;
|
||||||
|
}
|
||||||
|
&.b2 {
|
||||||
|
width: 56%;
|
||||||
|
margin: 9px 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 备注区恒占位(方案 B):min-height = 2 行折叠高 58.59px。
|
||||||
|
无备注时只留白、不出灰盒;有备注时灰盒至少填满该区(1 行备注也不缩)。
|
||||||
|
⇒ 加载 / 空周 / 有备注周三态同高,任意切换零高度变化 */
|
||||||
|
.wk-remark-slot {
|
||||||
|
margin: 8px 15px 0;
|
||||||
|
min-height: 58.59px;
|
||||||
|
}
|
||||||
|
/* 备注:与「安排数据」区分——上方一条分割线 + 浅底块,跟白色列表拉开层次 */
|
||||||
|
.wk-remark {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6672;
|
||||||
|
min-height: 58.59px;
|
||||||
|
.rk-txt {
|
||||||
|
line-height: 1.6;
|
||||||
|
// 备注可能是含换行的多行文本:pre-line 保留换行;折叠态截断 2 行
|
||||||
|
white-space: pre-line;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
// 右侧恒留 34px 给按钮:折叠态第二行以省略号收尾,展开态最后一行绕开按钮
|
||||||
|
padding-right: 34px;
|
||||||
|
&.expanded {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 「展开 / 收起」恒贴正文最后一行的右端(折叠=第二行、展开=最后一行),不另起一行
|
||||||
|
.rk-more {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 8px;
|
||||||
|
// 与备注块同底色,压住可能蹭到的正文尾部
|
||||||
|
background: #f7f8fa;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $wk-primary;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
&:hover {
|
||||||
|
color: $wk-primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #b9c2cc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 空态(模板内联 SVG,不依赖外部图片 / CSS 背景) ---------- */
|
||||||
|
.wg-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 16px 0 20px;
|
||||||
|
|
||||||
|
.txt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #98a2b0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineAsyncComponent } from "vue";
|
||||||
|
|
||||||
|
/** 组件键 → 内置组件。新增小组件只改这里 + widget_catalog 插一行。 */
|
||||||
|
export const widgetRegistry: Record<string, any> = {
|
||||||
|
evaluate: defineAsyncComponent(() => import("./EvaluateWidget.vue")),
|
||||||
|
syllabus: defineAsyncComponent(() => import("./SyllabusWidget.vue")),
|
||||||
|
weekplan: defineAsyncComponent(() => import("./WeekplanWidget.vue")),
|
||||||
|
};
|
||||||
@@ -27,7 +27,12 @@ import AppHeader from "@/components/AppHeader.vue";
|
|||||||
import UserInfoModal from "@/components/UserInfoModal.vue";
|
import UserInfoModal from "@/components/UserInfoModal.vue";
|
||||||
import mittBus from "@/utils/mitt";
|
import mittBus from "@/utils/mitt";
|
||||||
|
|
||||||
|
/** 命令面板取证用空壳路由:只需 route.name === "apps" 让 Header 渲染放大镜,
|
||||||
|
* 不挂真实 AppList(避免本机无后端时 getUserApp 失败污染控制台) */
|
||||||
|
const CmdEmptyShell = defineComponent({ setup: () => () => h("div", { style: "height:100%" }) });
|
||||||
|
|
||||||
const harnessRoutes: any[] = [
|
const harnessRoutes: any[] = [
|
||||||
|
{ path: "/apps", name: "apps", component: CmdEmptyShell, meta: { title: "应用" } },
|
||||||
{ path: "/msg", name: "Msg", component: MsgPage, meta: { title: "消息" } },
|
{ path: "/msg", name: "Msg", component: MsgPage, meta: { title: "消息" } },
|
||||||
{ path: "/settings", name: "Settings", component: SettingsPage, meta: { title: "设置" } },
|
{ path: "/settings", name: "Settings", component: SettingsPage, meta: { title: "设置" } },
|
||||||
{ path: "/theme-preview", name: "ThemePreview", component: ThemePreviewPage, meta: { title: "主题预览" } },
|
{ path: "/theme-preview", name: "ThemePreview", component: ThemePreviewPage, meta: { title: "主题预览" } },
|
||||||
@@ -73,12 +78,18 @@ function applyMockState() {
|
|||||||
],
|
],
|
||||||
curUserAppList: [
|
curUserAppList: [
|
||||||
{
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "常用应用",
|
||||||
apps: [
|
apps: [
|
||||||
{ appKey: "100019", appCode: "flow", name: "流程中心" },
|
{ appKey: "100019", appCode: "flow", name: "流程中心" },
|
||||||
{ appKey: "100617", appCode: "notice", name: "通知公告" },
|
{ appKey: "100617", appCode: "notice", name: "通知公告" },
|
||||||
{ appKey: "100611", appCode: "leave", name: "请假" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "教学应用",
|
||||||
|
apps: [{ appKey: "100611", appCode: "leave", name: "请假" }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
unreadCount: 5,
|
unreadCount: 5,
|
||||||
curUserSettingInfo: {
|
curUserSettingInfo: {
|
||||||
@@ -258,3 +269,25 @@ function runAssert(attempt: number) {
|
|||||||
document.title = "HARNESS-READY";
|
document.title = "HARNESS-READY";
|
||||||
}
|
}
|
||||||
setTimeout(() => runAssert(0), 1500);
|
setTimeout(() => runAssert(0), 1500);
|
||||||
|
|
||||||
|
// ===== 命令面板取证:?cmd=1[&q=课] 自动点开放大镜并可选输入;配合 #/apps =====
|
||||||
|
if (params.get("cmd") === "1") {
|
||||||
|
setTimeout(() => {
|
||||||
|
const btn = document.querySelector(".operate-container .icon-box") as HTMLElement | null;
|
||||||
|
btn?.click();
|
||||||
|
const q = params.get("q");
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
if (q) {
|
||||||
|
const inp = document.querySelector(".app-cmdk-hd input") as HTMLInputElement | null;
|
||||||
|
if (inp) {
|
||||||
|
inp.value = q;
|
||||||
|
inp.dispatchEvent(new Event("input"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => ((window as any).__CMDK_READY__ = true), 600);
|
||||||
|
},
|
||||||
|
q ? 500 : 600
|
||||||
|
);
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import LightApp from "@/views/LightApp.vue";
|
|||||||
import Stats from "@/views/Stats.vue";
|
import Stats from "@/views/Stats.vue";
|
||||||
import StatsRelay from "@/views/StatsRelay/index.vue";
|
import StatsRelay from "@/views/StatsRelay/index.vue";
|
||||||
import TeacherCopilot from "@/views/TeacherCopilot.vue";
|
import TeacherCopilot from "@/views/TeacherCopilot.vue";
|
||||||
import SysSetting from "@/views/SysSetting.vue";
|
|
||||||
import SysTempManage from "@/views/SysTempManage.vue";
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -59,18 +57,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: TeacherCopilot,
|
component: TeacherCopilot,
|
||||||
meta: { title: "教师助手" },
|
meta: { title: "教师助手" },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/syssetting",
|
|
||||||
name: "SysSetting",
|
|
||||||
component: SysSetting,
|
|
||||||
meta: { title: "系统设置" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/syssetting/tempmanage/:id",
|
|
||||||
name: "SysTempManage",
|
|
||||||
component: SysTempManage,
|
|
||||||
meta: { title: "模板管理" },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/msg",
|
path: "/msg",
|
||||||
name: "Msg",
|
name: "Msg",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface IdefaultStore {
|
|||||||
curUserSettingInfo: UserSettingInfo;
|
curUserSettingInfo: UserSettingInfo;
|
||||||
curUserMenuList: Array<any>; //当前角色菜单
|
curUserMenuList: Array<any>; //当前角色菜单
|
||||||
curUserAppList: Array<any>; //当前角色所有应用
|
curUserAppList: Array<any>; //当前角色所有应用
|
||||||
|
/** 应用列表加载态(AppList 拉取时写入,Header 命令面板共享同一状态;2026-10-08) */
|
||||||
|
appListLoading: boolean;
|
||||||
curRoleUid: string | null; //当前角色uid
|
curRoleUid: string | null; //当前角色uid
|
||||||
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加;Header 角标与 /msg 页左栏同源) */
|
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加;Header 角标与 /msg 页左栏同源) */
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@@ -53,6 +55,7 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
curUserMenuList: [],
|
curUserMenuList: [],
|
||||||
curRoleUid: null,
|
curRoleUid: null,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
appListLoading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -74,6 +77,9 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
setCurUserAppList(apps: any[]) {
|
setCurUserAppList(apps: any[]) {
|
||||||
this.curUserAppList = apps;
|
this.curUserAppList = apps;
|
||||||
},
|
},
|
||||||
|
setAppListLoading(v: boolean) {
|
||||||
|
this.appListLoading = v;
|
||||||
|
},
|
||||||
setCurUserMenuList(menus: any[]) {
|
setCurUserMenuList(menus: any[]) {
|
||||||
this.curUserMenuList = menus;
|
this.curUserMenuList = menus;
|
||||||
},
|
},
|
||||||
@@ -121,7 +127,8 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
getWidgetAppIds() {
|
getWidgetAppIds() {
|
||||||
const { widgetAppIds: appIds, widgetAppList: appList } = this.curUserSettingInfo;
|
const { widgetAppIds: appIds, widgetAppList: appList } = this.curUserSettingInfo;
|
||||||
let checkIds: number[] = [];
|
let checkIds: number[] = [];
|
||||||
if (appIds == null) checkIds = appList.map(x => x.id);
|
// getWidgetAppList 的比较器按 appIds 降序渲染(新添加置顶),故未配置态需预反转,才与 widget_catalog.sort 正序一致
|
||||||
|
if (appIds == null) checkIds = appList.map(x => x.id).reverse();
|
||||||
else if (appIds.length == 0) checkIds = [];
|
else if (appIds.length == 0) checkIds = [];
|
||||||
else checkIds = appIds.split(",").map(x => parseInt(x));
|
else checkIds = appIds.split(",").map(x => parseInt(x));
|
||||||
return checkIds;
|
return checkIds;
|
||||||
|
|||||||
@@ -51,6 +51,40 @@ export interface WidgetPositionResp extends WidgetPosition {
|
|||||||
schId?: number;
|
schId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小组件候选池目录行(widget_catalog + 管理端视角附加字段) */
|
||||||
|
export interface WidgetCatalog {
|
||||||
|
id?: number;
|
||||||
|
/** 组件键:内置 = 前端 widgetRegistry 的键;自定义 = 自定义标识 */
|
||||||
|
code: string;
|
||||||
|
/** 显示名(校管视角已按「校级别名 > 角色命名 > 名称」合成) */
|
||||||
|
name: string;
|
||||||
|
/** 1 = 前端内置组件 2 = 自定义 HTML 片段 */
|
||||||
|
compType: number;
|
||||||
|
content?: string | null;
|
||||||
|
dataCode?: string | null;
|
||||||
|
/** 下发范围:0 = 全部学校 1 = 白名单 2 = 黑名单 */
|
||||||
|
scopeType?: number;
|
||||||
|
/** 点名的学校 id 列表(scopeType=1/2 时有效) */
|
||||||
|
scopeSchIds?: number[];
|
||||||
|
/** 可见角色:逗号分隔 RoleTypeEnum;NULL/空 = 全角色 */
|
||||||
|
roleTypes?: string | null;
|
||||||
|
/** 角色专属命名 JSON 文本:{"2":"全校课表"} */
|
||||||
|
roleAlias?: string | null;
|
||||||
|
/** 校管视角:本校别名 */
|
||||||
|
alias?: string | null;
|
||||||
|
/** 校管视角:超管下发的全局名称 */
|
||||||
|
originalName?: string | null;
|
||||||
|
/** 校管视角:本校是否已有覆盖行 */
|
||||||
|
covered?: boolean;
|
||||||
|
/** 外跳入口:目标子应用 code(应用注册表 app_code),NULL/空 = 不显示入口 */
|
||||||
|
linkAppCode?: string | null;
|
||||||
|
/** 外跳入口:子应用内路由(如 /weekplan);绝对地址由后端按注册表 pc_url 解析 */
|
||||||
|
linkPath?: string | null;
|
||||||
|
sort?: number;
|
||||||
|
enabled?: number;
|
||||||
|
updateTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BschTransferRoleInfo {
|
export interface BschTransferRoleInfo {
|
||||||
roleInfo: RoleInfo;
|
roleInfo: RoleInfo;
|
||||||
ticket: string;
|
ticket: string;
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ export function bschLinkPage(options: LinkPageOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开应用页面(与 AppList.vue 点击应用图标同一条路径):
|
||||||
|
* 默认新窗口打开;命中 iframe 白名单($conf.ifameAppIdArr)则走站内 iframe(带 Header)。 */
|
||||||
|
export function openAppPage(app: any, ifameAppIdArr: number[] = []) {
|
||||||
|
const options: LinkPageOptions = {
|
||||||
|
url: app.webAddr,
|
||||||
|
type: LinkOpenTypeEnum.Open,
|
||||||
|
title: app.name,
|
||||||
|
};
|
||||||
|
if (ifameAppIdArr.includes(app.id)) {
|
||||||
|
options.type = LinkOpenTypeEnum.IFrame;
|
||||||
|
options.isHeader = true;
|
||||||
|
}
|
||||||
|
bschLinkPage(options);
|
||||||
|
}
|
||||||
|
|
||||||
export function isTickeRoterByName(name: string) {
|
export function isTickeRoterByName(name: string) {
|
||||||
return ["BschAppDetails", "AppDetails"].includes(name);
|
return ["BschAppDetails", "AppDetails"].includes(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type MittType<T = any> = {
|
|||||||
openBschIframePage: IframePageParmas; //打开iframe页面
|
openBschIframePage: IframePageParmas; //打开iframe页面
|
||||||
showLightAppModal: LightAppModal; //打开lightapp模态框
|
showLightAppModal: LightAppModal; //打开lightapp模态框
|
||||||
searchReset: void; //重置搜索框
|
searchReset: void; //重置搜索框
|
||||||
searchChange: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 类型
|
// 类型
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。
|
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261011-1332-246e1e3),勿手改。
|
||||||
* 分发方式: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> 内先行挂好,本模块只负责读写与切换。
|
||||||
*/
|
*/
|
||||||
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
||||||
|
|
||||||
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
|
/** 定稿默认档:blue + side-light + grad-on + 侧栏跟随(2026-10-11 翻转:什么都不配=浅色,深色需主动开启) */
|
||||||
export interface ThemeState {
|
export interface ThemeState {
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
/** 侧栏深色(false=浅色档) */
|
/** 侧栏深色(false=浅色档) */
|
||||||
@@ -30,7 +30,7 @@ export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
|||||||
|
|
||||||
export const DEFAULT_THEME_STATE: ThemeState = {
|
export const DEFAULT_THEME_STATE: ThemeState = {
|
||||||
theme: "blue",
|
theme: "blue",
|
||||||
sideDeep: true,
|
sideDeep: false,
|
||||||
gradOn: true,
|
gradOn: true,
|
||||||
sideFollow: true
|
sideFollow: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import conf from "@/assets/config/host.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 HTML 片段包装(方案 §5.2):门户拼两段注入,顺序不能合并成一段——
|
||||||
|
* ① 取数桥必须在 head 且早于片段自身脚本(否则片段里 yhWidget 为 undefined、内容永远空白);
|
||||||
|
* ② 高度上报必须在片段内容之后。管理员只贴正文,不需要自己写取数和上报。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 平台统一信封 {code,msg,data}:片段用原生 fetch,需自行解出 data 业务体 */
|
||||||
|
const BRIDGE =
|
||||||
|
"window.yhWidget={getData:function(params){" +
|
||||||
|
"return fetch(YH_WIDGET_API+'?code='+YH_WIDGET_CODE+'¶ms='+encodeURIComponent(params||'')," +
|
||||||
|
"{credentials:'include',headers:{yschapirequest:'1'}})" +
|
||||||
|
".then(function(r){return r.json()})" +
|
||||||
|
".then(function(j){return j&&j.data!==undefined?j.data:j});}};";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ② 高度上报:先立即报一次(门户据此关掉骨架、把 iframe 从 display:none 放出来),
|
||||||
|
* 之后 ResizeObserver 触发节流补报。节流必须用 setTimeout 而不能用 rAF ——
|
||||||
|
* iframe 隐藏期间子文档不出动画帧,rAF 回调永不回来,会把节流标志永久占住、后续上报全被吞
|
||||||
|
* (实测帧高卡在 min-height,内容撑出内层滚动条)。
|
||||||
|
*/
|
||||||
|
const REPORTER =
|
||||||
|
"(function(){var timer=0;" +
|
||||||
|
"function send(){try{parent.postMessage(JSON.stringify({id:YH_WIDGET_ID," +
|
||||||
|
"height:document.documentElement.scrollHeight}),'*')}catch(e){}}" +
|
||||||
|
"function up(){if(timer)return;timer=setTimeout(function(){timer=0;send()},80)}" +
|
||||||
|
"if(window.ResizeObserver){try{new ResizeObserver(up).observe(document.body)}catch(e){}}" +
|
||||||
|
"window.addEventListener('load',up);send();setTimeout(up,300);setTimeout(up,1200);})();";
|
||||||
|
|
||||||
|
const RESET =
|
||||||
|
"html,body{margin:0;padding:0;background:transparent;color:#344449;font-size:13px;line-height:1.5;" +
|
||||||
|
'font-family:"Helvetica Neue",Helvetica,"PingFang SC","Microsoft YaHei",Arial,sans-serif;}';
|
||||||
|
|
||||||
|
/** 闭合标签必须拆开拼:否则提前闭合门户自身的 <script> */
|
||||||
|
function scriptTag(code: string) {
|
||||||
|
return "<" + "script>" + code + "<" + "/script>";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapFragment(html: string, id: number, dataCode: string): string {
|
||||||
|
const apiUrl = JSON.stringify(`${conf.serviceHost}/api/getWidgetData`);
|
||||||
|
const code = JSON.stringify(encodeURIComponent(dataCode || ""));
|
||||||
|
const widgetId = String(Number(id) || 0);
|
||||||
|
const bridge = scriptTag(
|
||||||
|
BRIDGE.replace(/YH_WIDGET_API/g, () => apiUrl)
|
||||||
|
.replace(/YH_WIDGET_CODE/g, () => code)
|
||||||
|
);
|
||||||
|
const reporter = scriptTag(REPORTER.replace(/YH_WIDGET_ID/g, () => widgetId));
|
||||||
|
return (
|
||||||
|
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
|
||||||
|
"<style>" +
|
||||||
|
RESET +
|
||||||
|
"</style>" +
|
||||||
|
bridge +
|
||||||
|
"</head><body>" +
|
||||||
|
(html || "") +
|
||||||
|
reporter +
|
||||||
|
"</body></html>"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-list-wrapper base-mask">
|
<div class="app-list-wrapper base-mask">
|
||||||
<vuescroll :ops="scrollOps">
|
<vuescroll :ops="scrollOps">
|
||||||
<template v-if="!isSearch" v-for="(item, index) in appList">
|
<template v-for="(item, index) in appList">
|
||||||
<h3 class="app-type-box">{{ item.name }}</h3>
|
<h3 class="app-type-box">{{ item.name }}</h3>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<a @click="linkPage(citem.webAddr, citem)">
|
<a @click="linkPage(citem)">
|
||||||
<AppIcon v-if="vectorIconOf(citem)" :name="vectorIconOf(citem)" scene="tile"
|
<AppIcon v-if="vectorIconOf(citem)" :name="vectorIconOf(citem)" scene="tile"
|
||||||
:tint="citem.iconColor" />
|
:tint="citem.iconColor" />
|
||||||
<img v-else :src="$utils.appIconPath(citem.icon)" />
|
<img v-else :src="$utils.appIconPath(citem.icon)" />
|
||||||
@@ -39,42 +39,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!!isSearch">
|
|
||||||
<div class="app-list-box" v-if="searchAppList.length > 0">
|
|
||||||
<template v-for="item in searchAppList">
|
|
||||||
<ContextMenu
|
|
||||||
class="app-container app-container__mask"
|
|
||||||
:title="item.name"
|
|
||||||
@on-change="onMenuChange"
|
|
||||||
@select="onMenuSel"
|
|
||||||
:menu="[
|
|
||||||
{
|
|
||||||
label: '添加到桌面',
|
|
||||||
appId: item.id,
|
|
||||||
id: '1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '新窗口打开',
|
|
||||||
appId: item.id,
|
|
||||||
url: item.webAddr,
|
|
||||||
id: '2',
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<a @click="linkPage(item.webAddr, item)">
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="no-search-box" v-else>
|
|
||||||
<img :src="requireImg('no-search-bg.png')" alt="" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</vuescroll>
|
</vuescroll>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -86,14 +50,12 @@ 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";
|
||||||
import { useDefaultStore } from "@/store";
|
import { useDefaultStore } from "@/store";
|
||||||
import { requireImg } from "@/utils/hook/useImg";
|
|
||||||
import mittBus from "@/utils/mitt";
|
import mittBus from "@/utils/mitt";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { BschLoadTypeEnum, DesktopAppTypeEnum, LinkOpenTypeEnum } from "@/types";
|
import { BschLoadTypeEnum, DesktopAppTypeEnum, LinkOpenTypeEnum } from "@/types";
|
||||||
import { flatten } from "lodash";
|
|
||||||
import UserSettingApi from "@/api/UserSettingApi";
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import { bschLinkPage } from "@/utils/bschLinkPage";
|
import { bschLinkPage, openAppPage } from "@/utils/bschLinkPage";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import { appiconKeyOf } from "@/constants/appIconRules";
|
import { appiconKeyOf } from "@/constants/appIconRules";
|
||||||
|
|
||||||
@@ -120,15 +82,8 @@ const router = useRouter();
|
|||||||
const store = useDefaultStore();
|
const store = useDefaultStore();
|
||||||
const { $conf, $utils } = useCurrentInstance();
|
const { $conf, $utils } = useCurrentInstance();
|
||||||
|
|
||||||
const searchAppText = ref<string | null>(null),
|
const appList = ref<any[]>([]);
|
||||||
appList = ref<any[]>([]);
|
|
||||||
|
|
||||||
const searchAppList = computed(() => {
|
|
||||||
const allAppList = flatten(appList.value.map(x => x.apps));
|
|
||||||
return allAppList.filter(x => x.name.indexOf(searchAppText.value) !== -1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSearch = computed(() => !!searchAppText.value);
|
|
||||||
const compMenuId = computed(() => {
|
const compMenuId = computed(() => {
|
||||||
let menuList = store.curUserMenuList.find(x => x.url == route.path);
|
let menuList = store.curUserMenuList.find(x => x.url == route.path);
|
||||||
return menuList ? menuList.id : -1;
|
return menuList ? menuList.id : -1;
|
||||||
@@ -137,20 +92,23 @@ const compMenuId = computed(() => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reloadAppData();
|
reloadAppData();
|
||||||
mittBus.emit("searchReset");
|
mittBus.emit("searchReset");
|
||||||
mittBus.on("searchChange", val => {
|
|
||||||
searchAppText.value = val;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
mittBus.emit("searchReset");
|
mittBus.emit("searchReset");
|
||||||
mittBus.off("searchChange");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => store.curRoleUid, reloadAppData);
|
watch(() => store.curRoleUid, reloadAppData);
|
||||||
|
|
||||||
async function reloadAppData() {
|
async function reloadAppData() {
|
||||||
|
store.setAppListLoading(true);
|
||||||
|
try {
|
||||||
const list = await BaseApi.getUserApp(store.getCurUserId, store.getCurSchId);
|
const list = await BaseApi.getUserApp(store.getCurUserId, store.getCurSchId);
|
||||||
appList.value = list.filter(x => x.apps.length > 0);
|
appList.value = list.filter(x => x.apps.length > 0);
|
||||||
|
// 写 store:Header 搜索命令面板与后台 WS 通知的应用名反查共用这一份(2026-10-08)
|
||||||
|
store.setCurUserAppList(appList.value);
|
||||||
|
} finally {
|
||||||
|
store.setAppListLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMenuChange(curDom: any, state: boolean) {
|
function onMenuChange(curDom: any, state: boolean) {
|
||||||
@@ -185,20 +143,8 @@ async function onLightAppSaveDesktop(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkPage(url: string, item: any) {
|
function linkPage(item: any) {
|
||||||
let options: Parameters<typeof bschLinkPage>[0];
|
openAppPage(item, $conf.ifameAppIdArr);
|
||||||
options = {
|
|
||||||
url,
|
|
||||||
type: LinkOpenTypeEnum.Open,
|
|
||||||
title: item.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($conf.ifameAppIdArr.includes(item.id)) {
|
|
||||||
options.type = LinkOpenTypeEnum.IFrame;
|
|
||||||
options.isHeader = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bschLinkPage(options);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -242,11 +188,4 @@ function linkPage(url: string, item: any) {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.no-search-box {
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
top: -50%;
|
|
||||||
-webkit-transform: translateY(40%);
|
|
||||||
transform: translateY(40%);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,768 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="syssetting-wrap fix" v-if="isAdmin">
|
|
||||||
<!-- 左侧机构树(仅超管) -->
|
|
||||||
<div v-if="isSuperAdmin" class="left-box fl">
|
|
||||||
<h4 class="tree-title">机构</h4>
|
|
||||||
<Tree :data="treeData" select-node @on-select-change="onTreeSelect" />
|
|
||||||
</div>
|
|
||||||
<div :class="['right-box', isSuperAdmin ? 'ml-315' : '']">
|
|
||||||
<!-- 面包屑:仅超管展示当前选中的机构路径 -->
|
|
||||||
<Breadcrumb v-if="isSuperAdmin" class="sch-crumb">
|
|
||||||
<BreadcrumbItem v-for="(n, i) in currentSchPath" :key="n.id">
|
|
||||||
<span :class="['crumb-item', i === currentSchPath.length - 1 ? 'is-last' : '']">{{ n.title }}</span>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</Breadcrumb>
|
|
||||||
<Tabs v-model="tabIndex" type="card" class="syssetting-tabs">
|
|
||||||
<TabPane label="基础设置" name="basic">
|
|
||||||
<div class="basic-wrap">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<h4 class="item-title">
|
|
||||||
学校Logo
|
|
||||||
<span class="tip">(单张,上传即覆盖;推荐尺寸 70x70)</span>
|
|
||||||
</h4>
|
|
||||||
<div class="logo-uploader">
|
|
||||||
<ul class="logo-ul">
|
|
||||||
<li v-for="(item, index) in logoList" :key="item.id">
|
|
||||||
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
|
|
||||||
{{ item.type == 0 ? "系统" : "校" }}
|
|
||||||
</label>
|
|
||||||
<img :src="item.url" alt="" />
|
|
||||||
<div class="operate" v-if="item.isEdit">
|
|
||||||
<a href="javascript:;" class="del" @click="onDeleteLogo(item)">删除</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="add-cell">
|
|
||||||
<Upload
|
|
||||||
class="logo-upload"
|
|
||||||
:on-success="logoSuccess"
|
|
||||||
:show-upload-list="false"
|
|
||||||
:action="logoUploadAction"
|
|
||||||
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
|
|
||||||
:with-credentials="true"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
>
|
|
||||||
<div class="add-box add-box--logo">
|
|
||||||
<SvgIcon name="upload-add" color="#939393" style="width: 22px; height: 22px" />
|
|
||||||
<p>上传Logo</p>
|
|
||||||
</div>
|
|
||||||
</Upload>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 壁纸 -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<h4 class="item-title">
|
|
||||||
壁纸
|
|
||||||
<span class="tip">(多张,与 Header「设置壁纸」弹窗数据互通;推荐尺寸 1920x1200)</span>
|
|
||||||
</h4>
|
|
||||||
<ul class="wallpaper-ul">
|
|
||||||
<li v-for="(item, index) in wallpaperList" :key="item.id">
|
|
||||||
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
|
|
||||||
{{ item.type == 0 ? "系统" : "校" }}
|
|
||||||
</label>
|
|
||||||
<img :src="item.url" alt="" />
|
|
||||||
<div class="operate" v-if="item.isEdit">
|
|
||||||
<a href="javascript:;" class="del" @click="onDeleteWallpaper(item)">删除</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="uploader">
|
|
||||||
<Upload
|
|
||||||
class="add-wrap tc"
|
|
||||||
:on-success="wallpaperSuccess"
|
|
||||||
:show-upload-list="false"
|
|
||||||
:action="wallpaperUploadAction"
|
|
||||||
accept="image/jpeg, image/jpg, image/png"
|
|
||||||
:with-credentials="true"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
>
|
|
||||||
<div class="add-box add-box--wallpaper">
|
|
||||||
<SvgIcon name="upload-add" color="#939393" style="width: 28px; height: 28px" />
|
|
||||||
<p>点击上传图片</p>
|
|
||||||
</div>
|
|
||||||
</Upload>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
|
|
||||||
<TabPane label="应用管理" name="app">
|
|
||||||
<div class="app-manage">
|
|
||||||
<Tabs v-model="roleTypeTab" @on-click="onRoleTypeChange">
|
|
||||||
<TabPane :label="item.v" :name="item.k + ''" v-for="item in roleTypeList" :key="item.k" />
|
|
||||||
<template #extra>
|
|
||||||
<Button class="fl" size="small" type="primary" @click="showCategory()">新增应用</Button>
|
|
||||||
</template>
|
|
||||||
</Tabs>
|
|
||||||
<div class="search-wrap fix">
|
|
||||||
<Input
|
|
||||||
placeholder="输入关键字搜索"
|
|
||||||
class="fr"
|
|
||||||
search
|
|
||||||
suffix="ios-search"
|
|
||||||
style="width: 200px"
|
|
||||||
@on-search="onSearch"
|
|
||||||
v-model="dataParmas.name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TablePage
|
|
||||||
:table-height="tableHeight"
|
|
||||||
:data-api="tableDataApi"
|
|
||||||
:columns="columns"
|
|
||||||
:page-params="pageParams"
|
|
||||||
:auto-load="true"
|
|
||||||
ref="refTablePage"
|
|
||||||
/>
|
|
||||||
<!-- 新增/编辑应用 -->
|
|
||||||
<Modal
|
|
||||||
v-model="isModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
width="600"
|
|
||||||
:title="currentId == null ? '新增应用' : '编辑应用'"
|
|
||||||
>
|
|
||||||
<Form ref="refFormData" class="mt-8 mr-24" :model="formData" :rules="ruleValidate" :label-width="120">
|
|
||||||
<FormItem label="应用名称:" prop="name">
|
|
||||||
<Input placeholder="请输入应用名称" v-model="formData.name" />
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="接口地址:" prop="apiUrl">
|
|
||||||
<Input placeholder="请输入接口地址" v-model="formData.apiUrl" />
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="接口参数:" prop="paramsKey">
|
|
||||||
<ul class="parmas-ul">
|
|
||||||
<li class="fix mb-12" v-for="(item, index) in parmasList" :key="index">
|
|
||||||
<span class="ivu-form-item-label">参数名称</span>
|
|
||||||
<Input class="fl mr-12" v-model="item.k" style="width: 80px" />
|
|
||||||
<template v-if="item.type == 1">
|
|
||||||
<span class="ivu-form-item-label">参数类型</span>
|
|
||||||
<span>userId</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type == 2">
|
|
||||||
<span class="ivu-form-item-label">参数类型</span>
|
|
||||||
<span>学校Id</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="ivu-form-item-label">参数定值</span>
|
|
||||||
<Input class="fl mr-12" style="width: 80px" v-model="item.v" />
|
|
||||||
</template>
|
|
||||||
<Button
|
|
||||||
v-if="item.type != 1"
|
|
||||||
type="error"
|
|
||||||
ghost
|
|
||||||
icon="md-remove"
|
|
||||||
class="ml-12"
|
|
||||||
@click="parmasList.splice(index, 1)"
|
|
||||||
></Button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="fix">
|
|
||||||
<Button class="fl" @click="parmasList.push({ k: '', v: '', type: 3 })">添加固定参数</Button>
|
|
||||||
<Button
|
|
||||||
v-if="!!currentId"
|
|
||||||
class="ml-8"
|
|
||||||
@click="parmasList.push({ k: 'schId', v: '', type: 2 })"
|
|
||||||
>
|
|
||||||
添加SchId参数
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="数据解析:" prop="dataKey">
|
|
||||||
<Input placeholder="请输入数据字段" v-model="formData.dataKey" />
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="isSubmitLoading" @click="onEditSave">提交</Button>
|
|
||||||
<Button @click="onReset">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
</TabPane>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="no-permission" v-else>无权限访问</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useDefaultStore } from "@/store";
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
|
||||||
import TablePage from "@/components/TablePage.vue";
|
|
||||||
import Utils from "@/utils";
|
|
||||||
import { Message } from "view-ui-plus";
|
|
||||||
import { OperatorEnum } from "@/types";
|
|
||||||
import { PageParams } from "@/types/Basics";
|
|
||||||
import SysSettingApi from "@/api/SysSettingApi";
|
|
||||||
import AppSettingApi, { AppCategoryItem } from "@/api/AppSettingApi";
|
|
||||||
import mittBus from "@/utils/mitt";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useDefaultStore();
|
|
||||||
const { $conf } = useCurrentInstance();
|
|
||||||
|
|
||||||
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
|
||||||
|
|
||||||
const isSuperAdmin = computed(() => store.userInfo.type == 1);
|
|
||||||
const isAdmin = computed(() => store.getIsAdmin);
|
|
||||||
|
|
||||||
const tabIndex = ref("basic");
|
|
||||||
const currentSchId = ref<number>(0);
|
|
||||||
|
|
||||||
const treeData = ref<any[]>([
|
|
||||||
{ id: 0, title: "平台默认设置", selected: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 当前选中的机构路径(面包屑),默认平台默认设置
|
|
||||||
const currentSchPath = ref<{ id: number; title: string }[]>([
|
|
||||||
{ id: 0, title: "平台默认设置" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
function findPathInTree(list: any[], id: number): { id: number; title: string }[] | null {
|
|
||||||
for (const n of list || []) {
|
|
||||||
if (n.id === id) return [{ id: n.id, title: n.title }];
|
|
||||||
if (n.children && n.children.length) {
|
|
||||||
const sub = findPathInTree(n.children, id);
|
|
||||||
if (sub) return [{ id: n.id, title: n.title }, ...sub];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTreeSelect(nodes: any[]) {
|
|
||||||
if (!nodes || nodes.length == 0) return;
|
|
||||||
const node = nodes[0];
|
|
||||||
currentSchId.value = node.id;
|
|
||||||
if (node.id === 0) {
|
|
||||||
currentSchPath.value = [{ id: 0, title: "平台默认设置" }];
|
|
||||||
} else {
|
|
||||||
const path = findPathInTree(treeData.value, node.id);
|
|
||||||
currentSchPath.value = path && path.length ? path : [{ id: node.id, title: node.title }];
|
|
||||||
}
|
|
||||||
loadBasicData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTree(list: any[]): any[] {
|
|
||||||
return (list || []).map((item: any) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.name,
|
|
||||||
children: item.children && item.children.length ? mapTree(item.children) : [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTreeData() {
|
|
||||||
SysSettingApi.getSchTree()
|
|
||||||
.then((resp: any) => {
|
|
||||||
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }, ...mapTree(resp.children)];
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 基础设置(Logo + 壁纸) ====================
|
|
||||||
|
|
||||||
const logoList = ref<any[]>([]);
|
|
||||||
const wallpaperList = ref<any[]>([]);
|
|
||||||
|
|
||||||
const logoUploadAction = computed(() => {
|
|
||||||
let url = $conf.serviceHost + "/api/logoUpload";
|
|
||||||
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
|
|
||||||
return url;
|
|
||||||
});
|
|
||||||
|
|
||||||
const wallpaperUploadAction = computed(() => {
|
|
||||||
let url = $conf.serviceHost + "/api/wallpaperUpload";
|
|
||||||
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
|
|
||||||
return url;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadBasicData() {
|
|
||||||
const [walls, logos] = await Promise.all([
|
|
||||||
SysSettingApi.getSchWallpaperManageList(isSuperAdmin.value ? currentSchId.value : undefined),
|
|
||||||
SysSettingApi.getSchLogoManageList(isSuperAdmin.value ? currentSchId.value : undefined),
|
|
||||||
]);
|
|
||||||
wallpaperList.value = walls || [];
|
|
||||||
logoList.value = logos || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function wallpaperSuccess() {
|
|
||||||
loadBasicData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function logoSuccess() {
|
|
||||||
loadBasicData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteWallpaper(item: any) {
|
|
||||||
await SysSettingApi.deleteWallById(item.id);
|
|
||||||
Message.success("删除成功");
|
|
||||||
loadBasicData();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteLogo(item: any) {
|
|
||||||
await SysSettingApi.deleteLogoById(item.id);
|
|
||||||
Message.success("删除成功");
|
|
||||||
loadBasicData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 应用管理(AppCategory) ====================
|
|
||||||
|
|
||||||
const roleTypeList = [
|
|
||||||
{ k: 1, v: "校管理员" },
|
|
||||||
{ k: 2, v: "教师" },
|
|
||||||
{ k: 3, v: "学生" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const roleTypeTab = ref("1");
|
|
||||||
const dataParmas = reactive({ name: "" });
|
|
||||||
const pageParams: PageParams = reactive({ page: 1, count: 10 });
|
|
||||||
const refTablePage = ref<any>(null);
|
|
||||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true });
|
|
||||||
|
|
||||||
const tableDataApi = (params: PageParams) =>
|
|
||||||
AppSettingApi.getAppCategoryList({
|
|
||||||
...params,
|
|
||||||
roleType: Number(roleTypeTab.value),
|
|
||||||
name: dataParmas.name || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = ref<any[]>([
|
|
||||||
{ type: "index", title: "序号", width: 70, align: "center" },
|
|
||||||
{ title: "应用名称", width: 200, align: "left", key: "name" },
|
|
||||||
{
|
|
||||||
title: "数据类别",
|
|
||||||
width: 100,
|
|
||||||
align: "left",
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
let find = roleTypeList.find(x => x.k == params.row.roleType);
|
|
||||||
return h("span", !find ? "未知" : find.v);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: "接口地址", align: "left", key: "apiUrl", tooltip: true },
|
|
||||||
{ title: "数据解析", align: "center", key: "dataKey" },
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
width: 180,
|
|
||||||
align: "center",
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
return [
|
|
||||||
Utils.operatingTextHandle(h, "编辑", "mr-2", () => showCategory(params.row.id)),
|
|
||||||
Utils.operatingTextHandle(h, "模板管理", "mr-2", () => {
|
|
||||||
let { id, name } = params.row;
|
|
||||||
router.push({ name: "SysTempManage", params: { id }, query: { name } });
|
|
||||||
}),
|
|
||||||
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteCategory(params.row.id)),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isSubmitLoading = ref(false);
|
|
||||||
const currentId = ref<number | null>(null);
|
|
||||||
const parmasList = ref<any[]>([]);
|
|
||||||
const isModal = ref(false);
|
|
||||||
const formData = reactive<AppCategoryItem>({ id: null as any, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
|
||||||
|
|
||||||
const ruleValidate = {
|
|
||||||
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
parmasList,
|
|
||||||
val => {
|
|
||||||
formData.paramsKey = JSON.stringify(val);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
function onRoleTypeChange() {
|
|
||||||
refTablePage.value?.reloadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch() {
|
|
||||||
refTablePage.value?.reloadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCategory(id?: number) {
|
|
||||||
if (typeof id === "undefined") {
|
|
||||||
currentId.value = null;
|
|
||||||
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
|
||||||
parmasList.value = [
|
|
||||||
{ k: "userId", v: "", type: 1 },
|
|
||||||
{ k: "schId", v: "", type: 2 },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const row = refTablePage.value.list.find((x: any) => x.id == id);
|
|
||||||
if (!row) return;
|
|
||||||
currentId.value = id;
|
|
||||||
Object.assign(formData, { id: row.id, name: row.name, apiUrl: row.apiUrl, dataKey: row.dataKey });
|
|
||||||
try {
|
|
||||||
parmasList.value = JSON.parse(row.paramsKey || "[]");
|
|
||||||
} catch (e) {
|
|
||||||
parmasList.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onEditSave() {
|
|
||||||
if (!formData.name) {
|
|
||||||
Message.info("请填写应用名称");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isSubmitLoading.value = true;
|
|
||||||
try {
|
|
||||||
const item: AppCategoryItem = { ...formData, roleType: Number(roleTypeTab.value) };
|
|
||||||
await AppSettingApi.saveAppCategory(item);
|
|
||||||
Message.success(`${currentId.value == null ? "添加" : "操作"}成功!`);
|
|
||||||
refTablePage.value.reloadData(currentId.value == null ? OperatorEnum.add : OperatorEnum.edit);
|
|
||||||
isModal.value = false;
|
|
||||||
isSubmitLoading.value = false;
|
|
||||||
currentId.value = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
isSubmitLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onReset() {
|
|
||||||
currentId.value = null;
|
|
||||||
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
|
||||||
parmasList.value = [];
|
|
||||||
isModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteCategory(id: number) {
|
|
||||||
try {
|
|
||||||
await AppSettingApi.deleteAppCategory(id);
|
|
||||||
Message.success("操作成功!");
|
|
||||||
refTablePage.value.reloadData(OperatorEnum.del);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 生命周期 ====================
|
|
||||||
|
|
||||||
async function onSwitchRole() {
|
|
||||||
if (!isAdmin.value) return;
|
|
||||||
if (isSuperAdmin.value) {
|
|
||||||
currentSchId.value = 0;
|
|
||||||
getTreeData();
|
|
||||||
}
|
|
||||||
await loadBasicData();
|
|
||||||
refTablePage.value?.reloadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!isAdmin.value) return;
|
|
||||||
if (isSuperAdmin.value) {
|
|
||||||
getTreeData();
|
|
||||||
}
|
|
||||||
await loadBasicData();
|
|
||||||
mittBus.on("switchRole", onSwitchRole);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
mittBus.off("switchRole", onSwitchRole);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.syssetting-wrap {
|
|
||||||
height: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 300px;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
.tree-title {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&.ml-315 {
|
|
||||||
margin-left: 315px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sch-crumb {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #f5f7fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #606266;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
.crumb-item {
|
|
||||||
&.is-last {
|
|
||||||
color: #303133;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ivu-breadcrumb-item-separator) {
|
|
||||||
margin: 0 8px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.syssetting-tabs {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-permission {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.basic-wrap {
|
|
||||||
height: calc(100% - 44px);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 18px 24px 24px;
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
margin-bottom: 28px;
|
|
||||||
|
|
||||||
& + .setting-item {
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px dashed #ebeef5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
line-height: 22px;
|
|
||||||
|
|
||||||
.tip {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-uploader {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-ul {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
position: relative;
|
|
||||||
width: 120px;
|
|
||||||
height: 90px;
|
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #c0c4cc;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #f7f8fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.add-cell {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wallpaper-ul {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
position: relative;
|
|
||||||
width: 250px;
|
|
||||||
height: 140px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid #ebeef5;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #c0c4cc;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.uploader {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强约束 view-ui-plus Upload 组件渲染层都撑满 li
|
|
||||||
li.uploader :deep(.ivu-upload),
|
|
||||||
li.uploader :deep(.ivu-upload-select) {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
top: 8px;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 18px;
|
|
||||||
|
|
||||||
&.sys {
|
|
||||||
background: #5979ea;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sch {
|
|
||||||
background: #45b972;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.operate {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 8px;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
a.del {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
color: #fff;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-box {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 1px dashed #dcdfe6;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fafbfc;
|
|
||||||
text-align: center;
|
|
||||||
color: #909399;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: border-color 0.2s, color 0.2s, background-color 0.2s;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #5979ea;
|
|
||||||
color: #5979ea;
|
|
||||||
background: #f3f6fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--logo {
|
|
||||||
width: 120px;
|
|
||||||
height: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--wallpaper {
|
|
||||||
width: 250px;
|
|
||||||
height: 140px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-upload {
|
|
||||||
display: block;
|
|
||||||
width: 120px;
|
|
||||||
height: 90px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-manage {
|
|
||||||
height: calc(100% - 44px);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parmas-ul {
|
|
||||||
.ivu-form-item-label {
|
|
||||||
float: left;
|
|
||||||
margin-right: 8px;
|
|
||||||
line-height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="tempmanage-wrap fix">
|
|
||||||
<div class="file-box fl">
|
|
||||||
<h4 class="file-title">{{ tempName }}</h4>
|
|
||||||
<ul class="file-list">
|
|
||||||
<li
|
|
||||||
v-for="item in fileList"
|
|
||||||
:key="item.name"
|
|
||||||
:class="{ active: currentFileName == item.name }"
|
|
||||||
@click="onSelectFile(item)"
|
|
||||||
>
|
|
||||||
<SvgIcon name="menu-2" size="14" color="#5979ea" style="margin-right: 6px" />
|
|
||||||
{{ item.name }}
|
|
||||||
</li>
|
|
||||||
<li v-if="fileList.length == 0" class="empty">暂无文件,请上传 zip/rar</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="main-box">
|
|
||||||
<div class="fix mb-12">
|
|
||||||
<Upload
|
|
||||||
:on-success="onUploadSuccess"
|
|
||||||
:show-upload-list="false"
|
|
||||||
:action="tempUploadAction"
|
|
||||||
:accept="fileAccept"
|
|
||||||
:data="uploadParams"
|
|
||||||
:with-credentials="true"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
class="fl"
|
|
||||||
>
|
|
||||||
<Button class="fl mr-12" type="primary" size="large">上传应用</Button>
|
|
||||||
</Upload>
|
|
||||||
<Button class="fl mr-12" type="primary" size="large" @click="downTemp">下载应用</Button>
|
|
||||||
<h4 class="title fl tc">{{ tempName }}模板空间</h4>
|
|
||||||
</div>
|
|
||||||
<div class="editor-box">
|
|
||||||
<Codemirror
|
|
||||||
v-model="currentContent"
|
|
||||||
:extensions="extensions"
|
|
||||||
:style="{ height: '100%' }"
|
|
||||||
:indent-with-tab="true"
|
|
||||||
placeholder="请选择左侧文件进行编辑"
|
|
||||||
/>
|
|
||||||
<div class="editor-footer fix">
|
|
||||||
<Button type="primary" :disabled="!currentFileName" :loading="isSaving" @click="saveTemp">
|
|
||||||
保存更改
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref, computed, onMounted } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { Codemirror } from "vue-codemirror";
|
|
||||||
import { javascript } from "@codemirror/lang-javascript";
|
|
||||||
import { css } from "@codemirror/lang-css";
|
|
||||||
import { html } from "@codemirror/lang-html";
|
|
||||||
import { xml } from "@codemirror/lang-xml";
|
|
||||||
import { json } from "@codemirror/lang-json";
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
|
||||||
import Utils from "@/utils";
|
|
||||||
import { Message } from "view-ui-plus";
|
|
||||||
import AppSettingApi from "@/api/AppSettingApi";
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const { $conf } = useCurrentInstance();
|
|
||||||
|
|
||||||
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
|
||||||
|
|
||||||
const appId = computed(() => (route.params.id as string) || "");
|
|
||||||
const tempName = computed(() => (route.query.name as string) || "");
|
|
||||||
|
|
||||||
const fileList = ref<any[]>([]);
|
|
||||||
const currentFileName = ref<string | null>(null);
|
|
||||||
const currentContent = ref<string>("");
|
|
||||||
const isSaving = ref(false);
|
|
||||||
const fileAccept = ".rar,.zip";
|
|
||||||
|
|
||||||
const tempUploadAction = computed(() => $conf.serviceHost + "/api/tempUpload");
|
|
||||||
const uploadParams = computed(() => ({ appModId: appId.value }));
|
|
||||||
|
|
||||||
const extensions = computed(() => {
|
|
||||||
if (!currentFileName.value) return [EditorView.lineWrapping];
|
|
||||||
const suffix = currentFileName.value.substring(currentFileName.value.lastIndexOf(".") + 1).toLowerCase();
|
|
||||||
let lang: any[] = [];
|
|
||||||
if (["js", "ts", "jsx", "tsx"].includes(suffix)) lang = [javascript()];
|
|
||||||
else if (suffix == "json") lang = [json()];
|
|
||||||
else if (["css", "less", "scss"].includes(suffix)) lang = [css()];
|
|
||||||
else if (suffix == "html" || suffix == "htm") lang = [html()];
|
|
||||||
else if (suffix == "xml") lang = [xml()];
|
|
||||||
return [...lang, EditorView.lineWrapping];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getFileList() {
|
|
||||||
const list = await AppSettingApi.getTempList(Number(appId.value));
|
|
||||||
fileList.value = list || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSelectFile(item: any) {
|
|
||||||
currentFileName.value = item.name;
|
|
||||||
await loadContent(item.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取文件内容:静态资源跨域读取,携带 yschapirequest 头触发后端 CORS 过滤器 */
|
|
||||||
async function loadContent(path: string) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(path, {
|
|
||||||
headers: { yschapirequest: "1" },
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
Message.error("读取文件失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentContent.value = await resp.text();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
Message.error("读取文件失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUploadSuccess() {
|
|
||||||
Message.success("上传成功!");
|
|
||||||
AppSettingApi.updateResDate(Number(appId.value));
|
|
||||||
getFileList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function downTemp() {
|
|
||||||
window.location.href =
|
|
||||||
$conf.serviceHost + `/api/downloadTemp?appModId=${appId.value}&name=${encodeURIComponent(tempName.value)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTemp() {
|
|
||||||
if (!currentFileName.value) return;
|
|
||||||
isSaving.value = true;
|
|
||||||
try {
|
|
||||||
await AppSettingApi.saveTempFile(Number(appId.value), currentFileName.value, currentContent.value);
|
|
||||||
Message.success("保存成功!");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getFileList();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.tempmanage-wrap {
|
|
||||||
height: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
|
|
||||||
.file-box {
|
|
||||||
width: 260px;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 15px;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.file-title {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #eaf1ff;
|
|
||||||
color: #5979ea;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.empty {
|
|
||||||
color: #999;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-box {
|
|
||||||
margin-left: 275px;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-left: 20px;
|
|
||||||
line-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-box {
|
|
||||||
height: calc(100% - 52px);
|
|
||||||
border: 1px solid #e8eaec;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:deep(.cm-editor) {
|
|
||||||
height: calc(100% - 48px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-footer {
|
|
||||||
height: 48px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-top: 1px solid #e8eaec;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
|
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
|
||||||
档位类与通道 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.vue + App.vue):.layout-wrap 作用域内放
|
||||||
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。
|
header-container / left-menu / path-nav,内容区用 .base-wrap-main > .main-wrapper > .main-container;
|
||||||
默认头像轮(2026-09-30):logo 换云海图形、用户胶囊补三态头像圆 + icon-logout、
|
通道皮肤(skin.src.css)的侧栏/顶栏/面包屑规则全部以 .layout-wrap 为祖先,故根必须挂该类,
|
||||||
右侧补静态配色胶囊、左菜单换 iconfont + 可展开子菜单(预览 markup 落后于通道皮肤修复)。 -->
|
侧栏深色/浅色/跟随顶栏、渐变Header 这些档位令牌(html.side-deep/.grad-on…)才会被消费到。
|
||||||
<div class="theme-preview-root">
|
观感(配色、侧栏形态、内容区卡片)由通道按 URL 覆盖参数(?yh-theme= 等)驱动的档位提供,本页不写死。 -->
|
||||||
|
<div class="theme-preview-root layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></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>
|
||||||
@@ -20,8 +21,7 @@
|
|||||||
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i class="iconfont icon-logout"></i></a>
|
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i class="iconfont icon-logout"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
|
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
|
||||||
置于 header-aside 之后:本页不吃 .layout-wrap 皮肤(flex order 失效、走 float),
|
顶栏是 flex,皮肤给 .theme-cap{order:1} / .header-aside{order:2} → 胶囊恒落在用户胶囊左侧。 -->
|
||||||
float:right 的 DOM 靠后者居左——才能复刻 Notice「配色在左、用户胶囊在右」 -->
|
|
||||||
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
|
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="left-menu">
|
<div class="left-menu">
|
||||||
@@ -44,13 +44,15 @@
|
|||||||
<div class="path-nav">
|
<div class="path-nav">
|
||||||
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
|
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-main">
|
<div class="base-wrap-main">
|
||||||
|
<div class="main-wrapper">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<h3 class="panel-title">通知列表</h3>
|
<h3 class="panel-title">通知列表</h3>
|
||||||
<Table :columns="columns" :data="rows" size="small" :border="true" />
|
<Table :columns="columns" :data="rows" size="small" :border="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -91,25 +93,17 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
/* 本页只补最小布局;配色/圆角/阴影等观感全部由通道 base.layout.css 的档位类驱动 */
|
/* 本页只补"铺满 iframe + 纯展示"这层壳;配色、顶栏/侧栏/面包屑观感、内容区卡片全部交给通道
|
||||||
|
(骨架 skeleton.src.css 出结构、皮肤 skin.src.css 出观感,皮肤规则以 .layout-wrap 为祖先——
|
||||||
|
故根元素同时挂 .layout-wrap,档位令牌变化才会实时反映到预览上)。 */
|
||||||
.theme-preview-root {
|
.theme-preview-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none; /* 纯展示:整页不可交互 */
|
pointer-events: none; /* 纯展示:整页不可交互 */
|
||||||
background: #f5f6f8;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
|
||||||
h4 {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
|
/* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
|
||||||
.cap-btn {
|
.cap-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -124,35 +118,17 @@ onMounted(() => {
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距(Notice 同款覆盖) */
|
/* 面包屑分隔符:预览用纯 span 复刻(Notice 侧是 iView Breadcrumb,皮肤只作用于 .ivu-breadcrumb-*) */
|
||||||
.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 {
|
||||||
margin: 0 8px;
|
margin: 0 8px;
|
||||||
color: #c5c8cd;
|
color: #c5c8cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-main {
|
/* 内容卡标题:通道未定义,本页补最小排版(色值取通道文本色变量,未加载时回退) */
|
||||||
position: fixed;
|
.panel-title {
|
||||||
top: calc(var(--topbar-h, 52px) + var(--crumb-h, 44px));
|
|
||||||
left: var(--side-w, 200px);
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
padding: 14px 24px 24px;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3138;
|
color: var(--text, #2c3138);
|
||||||
margin: 4px 0 12px;
|
margin: 4px 0 12px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -66,9 +66,10 @@
|
|||||||
:visible="dragObj.selVisible"
|
:visible="dragObj.selVisible"
|
||||||
@on-click="onWidgetSelItemClick"
|
@on-click="onWidgetSelItemClick"
|
||||||
trigger="custom"
|
trigger="custom"
|
||||||
|
transfer-class-name="wg-dd"
|
||||||
>
|
>
|
||||||
<div class="operate-btn" @click="showWidgetSelClick">
|
<div class="operate-btn" @click="showWidgetSelClick">
|
||||||
<SvgIcon class="fl" name="widget-add" size="24" color="#cbd5df" hoverColor="#fff" />
|
<SvgIcon class="fl" name="widget-add" size="24" color="currentColor" />
|
||||||
</div>
|
</div>
|
||||||
<template #list>
|
<template #list>
|
||||||
<DropdownMenu style="min-width: 120px; max-height: 200px; overflow-y: auto">
|
<DropdownMenu style="min-width: 120px; max-height: 200px; overflow-y: auto">
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
v-if="!!item.checked"
|
v-if="!!item.checked"
|
||||||
class="fr mt-3"
|
class="fr mt-3"
|
||||||
name="widget-checked"
|
name="widget-checked"
|
||||||
color="#2d8cf0"
|
color="currentColor"
|
||||||
size="14"
|
size="14"
|
||||||
></SvgIcon>
|
></SvgIcon>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
|
|
||||||
<div class="widget-header-drag" @mousedown="(e:any)=>dragObj.isDrag = e.buttons === 1"></div>
|
<div class="widget-header-drag" @mousedown="(e:any)=>dragObj.isDrag = e.buttons === 1"></div>
|
||||||
<div class="operate-btn" @click="onClickWidgetHide">
|
<div class="operate-btn" @click="onClickWidgetHide">
|
||||||
<SvgIcon class="fr" name="widget-sub" size="24" color="#cbd5df" hoverColor="#fff" />
|
<SvgIcon class="fr" name="widget-sub" size="24" color="currentColor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,6 +100,9 @@
|
|||||||
<div class="widget-item pr" v-for="item in widgetAppList">
|
<div class="widget-item pr" v-for="item in widgetAppList">
|
||||||
<div class="widget-app-header">
|
<div class="widget-app-header">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
|
<!-- 内置组件在 header 里的挂载点:组件用 <Teleport> 把自身控件放进来(如课表班级切换器) -->
|
||||||
|
<div class="widget-header-right">
|
||||||
|
<span class="widget-header-extra" :id="'wg-hdr-' + item.id"></span>
|
||||||
<SvgIcon
|
<SvgIcon
|
||||||
@click="onWidgetSelItemClick(item.id)"
|
@click="onWidgetSelItemClick(item.id)"
|
||||||
class="fr"
|
class="fr"
|
||||||
@@ -108,6 +112,16 @@
|
|||||||
hoverColor="#646566"
|
hoverColor="#646566"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ① front 工程内置组件:内容在代码库,不走 iframe -->
|
||||||
|
<component
|
||||||
|
v-if="item.compType === 1 && widgetRegistry[item.code]"
|
||||||
|
:is="widgetRegistry[item.code]"
|
||||||
|
:widget-id="item.id"
|
||||||
|
:overview-url="item.overviewUrl"
|
||||||
|
@loaded="onWidgetLoaded(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
<Skeleton
|
<Skeleton
|
||||||
style="padding: 15px"
|
style="padding: 15px"
|
||||||
:loading="item.loading"
|
:loading="item.loading"
|
||||||
@@ -115,20 +129,27 @@
|
|||||||
:paragraph="{ rows: 4 }"
|
:paragraph="{ rows: 4 }"
|
||||||
animated
|
animated
|
||||||
/>
|
/>
|
||||||
|
<!-- ② 自定义 HTML 片段:内容来自 widget_catalog.content,srcdoc 内联,无外部地址 -->
|
||||||
<iframe
|
<iframe
|
||||||
|
v-if="item.compType === 2 && !!item.content"
|
||||||
v-show="!item.loading"
|
v-show="!item.loading"
|
||||||
:style="{ height: item.height + 'px' }"
|
:style="{ height: item.height + 'px' }"
|
||||||
:id="'iframe_widget_' + item.id"
|
:id="'iframe_widget_' + item.id"
|
||||||
:data-id="item.id"
|
:data-id="item.id"
|
||||||
@load="onIframeLoad(item.id)"
|
@load="onIframeLoad(item.id)"
|
||||||
:src="item.url"
|
:srcdoc="widgetFragmentSrc(item)"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
class="widget-app-iframe"
|
class="widget-app-iframe"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
<!-- ③ 兜底:注册表未命中 / 片段内容为空,渲染空态而非白屏(拉取途中交给骨架) -->
|
||||||
|
<div v-else-if="!isFragmentPending(item)" class="no-data">该小工具暂不可用</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</vuescroll>
|
</vuescroll>
|
||||||
<template v-if="widgetAppList.length == 0">
|
<template v-if="widgetAppList.length == 0">
|
||||||
<div class="widget-empty-box">
|
<div class="widget-empty-box">
|
||||||
<SvgIcon name="widget-empty" color="#2d8cf0" size="120"></SvgIcon>
|
<!-- 用 -vec(纯矢量,颜色跟随 currentColor);widget-empty 是内嵌 PNG 的假 SVG,颜色烤死 -->
|
||||||
|
<SvgIcon name="widget-empty-vec" color="currentColor" size="120"></SvgIcon>
|
||||||
<h4>单击 + 按钮可添加小工具。</h4>
|
<h4>单击 + 按钮可添加小工具。</h4>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,8 +168,10 @@ import BaseApi from "@/api/BaseApi";
|
|||||||
import VueDragResize from "vue-drag-resize/src";
|
import VueDragResize from "vue-drag-resize/src";
|
||||||
import { useDefaultStore } from "@/store";
|
import { useDefaultStore } from "@/store";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import { widgetRegistry } from "@/components/widgets";
|
||||||
import { cloneDeep, debounce, difference, includes, once, pick, remove } from "lodash";
|
import { cloneDeep, debounce, difference, includes, once, pick, remove } from "lodash";
|
||||||
import UserSettingApi from "@/api/UserSettingApi";
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
import { wrapFragment } from "@/utils/widgetFragment";
|
||||||
import { bschLinkPage } from "@/utils/bschLinkPage";
|
import { bschLinkPage } from "@/utils/bschLinkPage";
|
||||||
import {
|
import {
|
||||||
BschLoadTypeEnum,
|
BschLoadTypeEnum,
|
||||||
@@ -193,9 +216,20 @@ const dragObj = reactive({
|
|||||||
parentH: 0,
|
parentH: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vuescroll 面板参数。
|
||||||
|
* ⚠️ scrollingX 必须显式 false:vuescroll 默认 true,此时 native-panel 会给 .__view 内联
|
||||||
|
* `width: fit-content; min-width: 100%`,`.widget-item` 的包含块就被「最长的 nowrap 文本」
|
||||||
|
* 撑宽(白卡 357px → 内容 ≈425px)⇒ 列表条目 text-overflow 永不触发(文字顶到卡边被硬裁)、
|
||||||
|
* 七日条按更宽容器等分(六/日被裁),面板 scrollWidth > clientWidth ⇒ 外框出横向滚动条。
|
||||||
|
* 关掉后 .__view 走 `width: 100%` 分支。面板内组件均不依赖横向滚动:
|
||||||
|
* 课表自带 .tt-scroll(纵向)、自定义片段是 iframe width:100%。
|
||||||
|
*/
|
||||||
const widgetOps = {
|
const widgetOps = {
|
||||||
vuescroll: {},
|
vuescroll: {},
|
||||||
scrollPanel: {},
|
scrollPanel: {
|
||||||
|
scrollingX: false,
|
||||||
|
},
|
||||||
rail: {},
|
rail: {},
|
||||||
bar: {
|
bar: {
|
||||||
background: "#333",
|
background: "#333",
|
||||||
@@ -269,6 +303,13 @@ watch(
|
|||||||
widgetAppList.value = val.map(x => {
|
widgetAppList.value = val.map(x => {
|
||||||
return { ...x, loading: true, height: 20 };
|
return { ...x, loading: true, height: 20 };
|
||||||
});
|
});
|
||||||
|
widgetAppList.value.forEach(item => {
|
||||||
|
// 注册表未命中的内置项会落到空态分支,留日志便于发现两处(widgetRegistry / widget_catalog)不同步
|
||||||
|
if (item.compType === 1 && !widgetRegistry[item.code]) {
|
||||||
|
console.warn("[widget] 内置类型未命中注册表:", item.code);
|
||||||
|
}
|
||||||
|
if (item.compType === 2) loadFragmentContent(item);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
@@ -396,6 +437,43 @@ function iframeCallback(ev: MessageEvent<any>) {
|
|||||||
});
|
});
|
||||||
//console.log(find, id, height);
|
//console.log(find, id, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内置组件自报高度:与 iframeCallback 同一收口(find.height),不再走 postMessage */
|
||||||
|
function onWidgetLoaded(id: number, height: number) {
|
||||||
|
const find = widgetAppList.value.find(x => x.id == id);
|
||||||
|
if (!find) return;
|
||||||
|
find.height = height;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
find.loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片段正文按需拉一次:不进 getUserSettings,避免每次进桌面下发全部 HTML */
|
||||||
|
async function loadFragmentContent(item: any) {
|
||||||
|
try {
|
||||||
|
const data: any = await UserSettingApi.getWidgetContent(item.code);
|
||||||
|
item.content = (data && data.content) || "";
|
||||||
|
// 取数键随内容一起回:候选池载荷里没有 data_code,桥要用它才知道打哪个数据键
|
||||||
|
item.dataCode = (data && data.dataCode) || item.code;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 片段内容拉取失败:", item.code, e);
|
||||||
|
item.content = "";
|
||||||
|
item.dataCode = item.code;
|
||||||
|
}
|
||||||
|
item.contentLoaded = true;
|
||||||
|
if (!item.content) {
|
||||||
|
const find = widgetAppList.value.find(x => x.id == item.id);
|
||||||
|
if (find) find.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFragmentPending(item: any) {
|
||||||
|
return item.compType === 2 && !item.contentLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetFragmentSrc(item: any) {
|
||||||
|
return wrapFragment(item.content || "", item.id, item.dataCode || item.code);
|
||||||
|
}
|
||||||
async function getAppList() {
|
async function getAppList() {
|
||||||
appList.value = await UserSettingApi.getUserDesktopAppList();
|
appList.value = await UserSettingApi.getUserDesktopAppList();
|
||||||
let arr = cloneDeep(appList.value);
|
let arr = cloneDeep(appList.value);
|
||||||
@@ -452,4 +530,25 @@ async function openLinkPage(item: any) {
|
|||||||
.vdr.active::before {
|
.vdr.active::before {
|
||||||
content: none;
|
content: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「+」添加小组件下拉:与卡片同族(玻璃层)。下拉就地渲染在 .widget-wrap 内,可直接复用其玻璃令牌
|
||||||
|
(--wg-glass-*)保持同族;底色在同色相上加深一档,保证浮层层次与白字可读性。 */
|
||||||
|
.ivu-select-dropdown.wg-dd {
|
||||||
|
padding: 4px 0;
|
||||||
|
background-color: rgba(15, 23, 42, 0.72);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
border: 1px solid var(--wg-glass-border, rgba(255, 255, 255, 0.18));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--wg-glass-shadow, 0 10px 36px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ivu-select-dropdown.wg-dd .ivu-dropdown-item {
|
||||||
|
color: var(--wg-fg, rgba(255, 255, 255, 0.94));
|
||||||
|
text-shadow: var(--wg-fg-shadow, 0 1px 2px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ivu-select-dropdown.wg-dd .ivu-dropdown-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import BaseApi from "@/api/BaseApi";
|
import BaseApi from "@/api/BaseApi";
|
||||||
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
||||||
@@ -392,6 +392,12 @@ onMounted(() => {
|
|||||||
loadApps();
|
loadApps();
|
||||||
mittBus.on("msgCenter:newMessage", onWsNewMessage);
|
mittBus.on("msgCenter:newMessage", onWsNewMessage);
|
||||||
});
|
});
|
||||||
|
// 切换身份后随新身份学校口径重拉(curRoleUid 由 App.vue switchRole 统一 bump,与桌面/应用中心同机制)
|
||||||
|
watch(() => store.curRoleUid, () => {
|
||||||
|
loadList(true);
|
||||||
|
loadSummary();
|
||||||
|
loadApps();
|
||||||
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
mittBus.off("msgCenter:newMessage", onWsNewMessage);
|
mittBus.off("msgCenter:newMessage", onWsNewMessage);
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
@@ -423,7 +429,7 @@ function fmtTime(sendTime?: string) {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
|
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
|
||||||
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
|
padding 与 .set-shell 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
|
||||||
.msg-shell {
|
.msg-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
|
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="wcard"
|
||||||
|
:class="{ off: !item.enabled, dragging, 'drag-over': dragOver }"
|
||||||
|
:draggable="draggable"
|
||||||
|
@mouseup="draggable = false"
|
||||||
|
@dragstart="onDragStart"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<div class="wc-top">
|
||||||
|
<span class="badge" :class="item.compType === 1 ? 'builtin' : 'custom'">{{ typeLabel }}</span>
|
||||||
|
<template v-if="item.compType === 1">
|
||||||
|
<span v-if="registryHit" class="badge ok" title="前端 widgetRegistry 已收录该键">注册表 ✓</span>
|
||||||
|
<span v-else class="badge warn" title="前端注册表未收录,用户侧会落「暂不可用」空态">⚠ 未收录</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="badge mute" title="片段正文存在 widget_catalog.content">内容存库</span>
|
||||||
|
<span class="drag" title="按住拖拽排序" @mousedown.stop="draggable = true">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor">
|
||||||
|
<circle cx="9" cy="5.5" r="1.6" />
|
||||||
|
<circle cx="15" cy="5.5" r="1.6" />
|
||||||
|
<circle cx="9" cy="12" r="1.6" />
|
||||||
|
<circle cx="15" cy="12" r="1.6" />
|
||||||
|
<circle cx="9" cy="18.5" r="1.6" />
|
||||||
|
<circle cx="15" cy="18.5" r="1.6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wc-name">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<span v-if="item.alias" class="badge alias" :title="`校级别名;超管下发名称:${item.originalName || item.name}`">别名</span>
|
||||||
|
<span v-if="adaptive" class="tag-adaptive">按角色适配</span>
|
||||||
|
</div>
|
||||||
|
<div class="wc-code">{{ item.code }}</div>
|
||||||
|
|
||||||
|
<div class="wc-meta">
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">取数键</span>
|
||||||
|
<span class="v">
|
||||||
|
<code v-if="item.dataCode">{{ item.dataCode }}</code>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">可见</span>
|
||||||
|
<span class="v">
|
||||||
|
<span class="rchip" :class="scopeCls">{{ scopeText }}</span>
|
||||||
|
<span v-if="roleAll" class="rchip all">全角色</span>
|
||||||
|
<template v-else>
|
||||||
|
<span v-for="r in roleList" :key="r" class="rchip">{{ roleNameOf(r) }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">更新</span>
|
||||||
|
<span class="v dim">{{ item.updateTime || "—" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wc-ft">
|
||||||
|
<span class="wc-ops">
|
||||||
|
<a class="op" @click="$emit('edit', item)">{{ isSuperView ? "编辑" : "设置" }}</a>
|
||||||
|
<a class="op" @click="$emit('preview', item)">预览</a>
|
||||||
|
<a v-if="isSuperView" class="op danger" @click="$emit('del', item)">删除</a>
|
||||||
|
</span>
|
||||||
|
<span class="wc-sw">
|
||||||
|
<span class="sw" :class="{ 'is-on': !!item.enabled }" @click="$emit('toggle', item)"></span>
|
||||||
|
<span class="wc-sw-t" :class="{ on: !!item.enabled }">{{ item.enabled ? "已启用" : "已下线" }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import type { WidgetCatalog } from "@/types";
|
||||||
|
import { RoleTypeEnum } from "@/types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: WidgetCatalog;
|
||||||
|
isSuperView: boolean;
|
||||||
|
registryHit: boolean;
|
||||||
|
schools: { id: number; name: string }[];
|
||||||
|
dragging: boolean;
|
||||||
|
dragOver: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "edit" | "preview" | "toggle" | "del", item: WidgetCatalog): void;
|
||||||
|
(e: "dragstart" | "dragover" | "drop", id: number | undefined): void;
|
||||||
|
(e: "dragend"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const draggable = ref(false);
|
||||||
|
|
||||||
|
const typeLabel = computed(() => (props.item.compType === 1 ? "内置组件" : "自定义片段"));
|
||||||
|
const adaptive = computed(() => props.item.compType === 1 && props.item.code === "syllabus");
|
||||||
|
|
||||||
|
const roleKeys = computed(() => {
|
||||||
|
const raw = props.item.roleTypes;
|
||||||
|
if (!raw) return [] as number[];
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map(x => Number(x.trim()))
|
||||||
|
.filter(x => !isNaN(x))
|
||||||
|
.sort();
|
||||||
|
});
|
||||||
|
const roleAll = computed(() => roleKeys.value.length === 0 || roleKeys.value.length === 3);
|
||||||
|
const roleList = computed(() => (roleAll.value ? [] : roleKeys.value));
|
||||||
|
const roleNameOf = (k: number) => ({ [RoleTypeEnum.Teacher]: "教师", [RoleTypeEnum.SchAdmin]: "校领导", [RoleTypeEnum.Student]: "学生" })[k] || `角色 ${k}`;
|
||||||
|
|
||||||
|
const schoolNameOf = (id: number) => props.schools.find(x => x.id === id)?.name || `学校 ${id}`;
|
||||||
|
const scopeCls = computed(() => (props.item.scopeType === 1 ? "sch" : props.item.scopeType === 2 ? "no" : "all"));
|
||||||
|
const scopeText = computed(() => {
|
||||||
|
const names = (props.item.scopeSchIds || []).map(schoolNameOf).join("、");
|
||||||
|
if (props.item.scopeType === 1) return `指定可见 ${names}`;
|
||||||
|
if (props.item.scopeType === 2) return `指定不可见 ${names}`;
|
||||||
|
return "全部学校";
|
||||||
|
});
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent) {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", String(props.item.id));
|
||||||
|
}
|
||||||
|
emit("dragstart", props.item.id);
|
||||||
|
}
|
||||||
|
function onDragOver() {
|
||||||
|
if (draggable.value) emit("dragover", props.item.id);
|
||||||
|
}
|
||||||
|
function onDrop() {
|
||||||
|
emit("drop", props.item.id);
|
||||||
|
draggable.value = false;
|
||||||
|
}
|
||||||
|
function onDragEnd() {
|
||||||
|
draggable.value = false;
|
||||||
|
emit("dragend");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "sass:color";
|
||||||
|
@use "../../assets/styles/utils/varible" as *;
|
||||||
|
|
||||||
|
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
|
||||||
|
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
|
||||||
|
$wm-brand-line: color.mix(#ffffff, $wm-brand, 78%);
|
||||||
|
|
||||||
|
.wcard {
|
||||||
|
--wm-t1: #2c3138;
|
||||||
|
--wm-t2: #4a4f55;
|
||||||
|
--wm-t3: #7a8088;
|
||||||
|
--wm-t4: #9aa0a8;
|
||||||
|
--wm-line: #eef0f3;
|
||||||
|
--wm-line2: #e5e8ee;
|
||||||
|
--wm-ok: #12a150;
|
||||||
|
--wm-ok-bg: #e8f7ee;
|
||||||
|
--wm-warn: #b54708;
|
||||||
|
--wm-warn-bg: #fff4e5;
|
||||||
|
--wm-danger: #d92d20;
|
||||||
|
--wm-danger-bg: #fdecea;
|
||||||
|
|
||||||
|
border: 1px solid var(--wm-line2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 13px 14px 0;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.16s, box-shadow 0.16s, transform 0.16s;
|
||||||
|
position: relative;
|
||||||
|
// 同一行卡片等高、底部操作行对齐(网格 stretch + 底部自适应)
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $wm-brand-line;
|
||||||
|
box-shadow: 0 6px 18px rgba($wm-brand, 0.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
&.off {
|
||||||
|
background: #fcfcfd;
|
||||||
|
.wc-name {
|
||||||
|
color: var(--wm-t3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
&.drag-over {
|
||||||
|
border-color: $wm-brand;
|
||||||
|
box-shadow: 0 0 0 2px rgba($wm-brand, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
&.builtin {
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
color: $wm-brand;
|
||||||
|
}
|
||||||
|
&.custom {
|
||||||
|
background: var(--wm-warn-bg);
|
||||||
|
color: var(--wm-warn);
|
||||||
|
}
|
||||||
|
&.ok {
|
||||||
|
background: var(--wm-ok-bg);
|
||||||
|
color: var(--wm-ok);
|
||||||
|
}
|
||||||
|
&.warn {
|
||||||
|
background: var(--wm-danger-bg);
|
||||||
|
color: var(--wm-danger);
|
||||||
|
}
|
||||||
|
&.mute {
|
||||||
|
background: #f0f1f3;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
}
|
||||||
|
&.alias {
|
||||||
|
background: #eaf6ff;
|
||||||
|
color: #1677b3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
.drag {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: grab;
|
||||||
|
color: #c4c9d2;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&:hover {
|
||||||
|
color: $wm-brand;
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wc-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wm-t1);
|
||||||
|
margin-top: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tag-adaptive {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #eaf6ff;
|
||||||
|
color: #1677b3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wc-code {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.wc-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--wm-line2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
.k {
|
||||||
|
width: 46px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
}
|
||||||
|
.v {
|
||||||
|
color: var(--wm-t2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: var(--wm-t4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rchip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 19px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #5b6472;
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
&.all {
|
||||||
|
background: #f0f1f3;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
}
|
||||||
|
&.sch {
|
||||||
|
background: #eaf6ff;
|
||||||
|
color: #1677b3;
|
||||||
|
}
|
||||||
|
&.no {
|
||||||
|
background: var(--wm-danger-bg);
|
||||||
|
color: var(--wm-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wc-ft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 9px 0 11px;
|
||||||
|
border-top: 1px solid var(--wm-line);
|
||||||
|
}
|
||||||
|
.wc-ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 11px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.op {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: $wm-brand;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
&.danger {
|
||||||
|
color: var(--wm-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wc-sw {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sw {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 38px;
|
||||||
|
height: 21px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #d5d8de;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: left 0.18s;
|
||||||
|
}
|
||||||
|
&.is-on {
|
||||||
|
background: $wm-brand;
|
||||||
|
&::after {
|
||||||
|
left: 19px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wc-sw-t {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
&.on {
|
||||||
|
color: var(--wm-ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
<template>
|
||||||
|
<div class="widget-manage">
|
||||||
|
<!-- 视角切换:超管可切到校管理员视角预览可写范围;纯校管锁定在校管视角 -->
|
||||||
|
<div class="wm-viewbar">
|
||||||
|
<div v-if="isSuperUser" class="seg-tabs">
|
||||||
|
<div class="sg" :class="{ active: view === 'super' }" @click="setView('super')">超管视角</div>
|
||||||
|
<div class="sg" :class="{ active: view === 'school' }" @click="setView('school')">校管理员视角</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="role-chip">学校管理员</span>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span class="vb-tip">{{ isSuperView ? "超管视角 · 全部字段可改" : "校管视角 · 只能改本校的 启用 / 可见角色 / 排序 / 别名" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-bar key">
|
||||||
|
<span>🎯</span>
|
||||||
|
<span><b>这张卡能不能在桌面「+」里被勾选 = 三个条件同时成立:</b>① 启用 = 开 <b>AND</b> ② 可见范围命中当前学校
|
||||||
|
<b>AND</b> ③ 可见角色命中当前登录人角色。缺一即不进候选池。<b>名称、内容、取数只影响「勾选之后长什么样」,不影响「能不能勾」。</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="note-bar warn">
|
||||||
|
<span>🔑</span>
|
||||||
|
<span v-if="isSuperView"><b>当前视角:超管(type==1)</b> —— 可新增 / 编辑内容 / 删除、可登记内置组件、可配「可见范围(全部 / 指定可见 / 指定不可见)」。切到「校管理员视角」看校级权限差异。</span>
|
||||||
|
<span v-else><b>当前视角:校管理员</b> —— 只能改本校({{ mySchName || "当前学校" }})已下发组件的
|
||||||
|
<b>启用 / 可见角色 / 排序 / 别名</b>;<b>没有</b>新增、改内容、删除、「指定学校范围」的入口。⚠️ 后端强制按登录校落库,不接受前端传入的 schId。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-toolbar">
|
||||||
|
<div class="seg-tabs">
|
||||||
|
<div class="sg" :class="{ active: typeFilter === 'all' }" @click="typeFilter = 'all'">全部 <b>{{ items.length }}</b></div>
|
||||||
|
<div class="sg" :class="{ active: typeFilter === '1' }" @click="typeFilter = '1'">内置组件 <b>{{ countBuiltin }}</b></div>
|
||||||
|
<div class="sg" :class="{ active: typeFilter === '2' }" @click="typeFilter = '2'">自定义片段 <b>{{ countCustom }}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="tb-right">
|
||||||
|
<label class="search">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="M21 21l-3.6-3.6" />
|
||||||
|
</svg>
|
||||||
|
<input v-model="keyword" placeholder="搜索名称 / 组件键 / 取数键" />
|
||||||
|
</label>
|
||||||
|
<button v-if="isSuperView" class="btn sm" @click="regVisible = true">+ 登记内置组件</button>
|
||||||
|
<button v-if="isSuperView" class="btn primary sm" @click="openNew">+ 新建自定义片段</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="wm-loading">加载中…</div>
|
||||||
|
<div v-else class="wgrid">
|
||||||
|
<div v-if="items.length === 0" class="empty-state">
|
||||||
|
<div class="es-ic">
|
||||||
|
<SvgIcon name="widget-empty-line" size="72" color="#c4c9d2" />
|
||||||
|
</div>
|
||||||
|
<h5>候选池是空的</h5>
|
||||||
|
<p>新环境不会自动种数据(手工种子只在 <code>resources/sql/widget_catalog.sql</code>,启动期 seeding 仅在库里一行都没有时才补 3 个内置键)。<br />可先把前端注册表里已有的内置组件登记进来。</p>
|
||||||
|
<div class="es-actions">
|
||||||
|
<button v-if="isSuperView" class="btn primary" :disabled="registering" @click="quickRegisterBuiltins">
|
||||||
|
一键登记 {{ registryCodes.length }} 个内置组件
|
||||||
|
</button>
|
||||||
|
<span v-else class="es-dim">请联系平台超管登记内置组件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<WidgetCard
|
||||||
|
v-for="item in visibleList"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
:is-super-view="isSuperView"
|
||||||
|
:registry-hit="!!widgetRegistry[item.code]"
|
||||||
|
:schools="cardSchools"
|
||||||
|
:dragging="dragFrom === item.id"
|
||||||
|
:drag-over="dragTo === item.id"
|
||||||
|
@edit="openEdit"
|
||||||
|
@preview="openPreview"
|
||||||
|
@toggle="onToggle"
|
||||||
|
@del="onDelete"
|
||||||
|
@dragstart="onDragStart"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@drop="onDrop"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
/>
|
||||||
|
<div v-if="visibleList.length === 0" class="empty-state slim">
|
||||||
|
<p>没有匹配的组件,换个关键字或切回「全部」。</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSuperView" class="wcard add" @click="openNew">
|
||||||
|
<span class="plus">+</span>
|
||||||
|
<span>新建自定义片段</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-bar">
|
||||||
|
<span>ℹ️</span>
|
||||||
|
<span><b>可见范围三态</b>(超管专属):<code>全部学校</code> / <code>指定学校可见</code>(白名单,只有选中的学校下发)/
|
||||||
|
<code>指定学校不可见</code>(黑名单,除选中学校外都下发)。<br />校级可覆盖项(校管理员):<b>启用 / 可见角色 / 排序 / 别名</b>。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WidgetEditModal
|
||||||
|
:visible="editVisible"
|
||||||
|
:item="editItem"
|
||||||
|
:is-new="editIsNew"
|
||||||
|
:is-super-view="isSuperView"
|
||||||
|
:initial-tab="editTab"
|
||||||
|
:schools="schools"
|
||||||
|
:my-sch-id="mySchId"
|
||||||
|
@close="editVisible = false"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
<WidgetRegisterModal
|
||||||
|
:visible="regVisible"
|
||||||
|
:registry-codes="registryCodes"
|
||||||
|
:registered-codes="registeredCodes"
|
||||||
|
:next-sort="nextSort"
|
||||||
|
@close="regVisible = false"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
import { widgetRegistry } from "@/components/widgets";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import { useDefaultStore } from "@/store";
|
||||||
|
import type { WidgetCatalog } from "@/types";
|
||||||
|
import WidgetCard from "./WidgetCard.vue";
|
||||||
|
import WidgetEditModal from "./WidgetEditModal.vue";
|
||||||
|
import WidgetRegisterModal from "./WidgetRegisterModal.vue";
|
||||||
|
|
||||||
|
const store = useDefaultStore();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const registering = ref(false);
|
||||||
|
const items = ref<WidgetCatalog[]>([]);
|
||||||
|
const schools = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const typeFilter = ref<"all" | "1" | "2">("all");
|
||||||
|
const keyword = ref("");
|
||||||
|
|
||||||
|
/** 超管(userInfo.type==1)与校管(roleId∈{2,5,7})都可进本页,但可写范围不同 */
|
||||||
|
const isSuperUser = computed(() => store.userInfo?.type == 1);
|
||||||
|
const view = ref<"super" | "school">(isSuperUser.value ? "super" : "school");
|
||||||
|
const isSuperView = computed(() => isSuperUser.value && view.value === "super");
|
||||||
|
const mySchId = computed<number | null>(() => {
|
||||||
|
const id = store.getCurSchId;
|
||||||
|
return id && id > 0 ? id : null;
|
||||||
|
});
|
||||||
|
const mySchName = computed(() => store.curRoleInfo?.schName || "");
|
||||||
|
|
||||||
|
const editVisible = ref(false);
|
||||||
|
const editIsNew = ref(false);
|
||||||
|
const editItem = ref<WidgetCatalog | null>(null);
|
||||||
|
const editTab = ref<"cfg" | "cont" | "prev">("cfg");
|
||||||
|
const regVisible = ref(false);
|
||||||
|
|
||||||
|
const registryCodes = Object.keys(widgetRegistry);
|
||||||
|
const registeredCodes = computed(() => items.value.filter(x => x.compType === 1).map(x => x.code));
|
||||||
|
const nextSort = computed(() => items.value.reduce((max, x) => Math.max(max, Number(x.sort) || 0), 0) + 10);
|
||||||
|
const countBuiltin = computed(() => items.value.filter(x => x.compType === 1).length);
|
||||||
|
const countCustom = computed(() => items.value.filter(x => x.compType === 2).length);
|
||||||
|
/** 卡片里可见范围 chip 的学校名:超管取机构树,校管只有本校 */
|
||||||
|
const cardSchools = computed(() => {
|
||||||
|
if (schools.value.length > 0) return schools.value;
|
||||||
|
return mySchId.value ? [{ id: mySchId.value, name: mySchName.value || "本校" }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleList = computed(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase();
|
||||||
|
return items.value.filter(x => {
|
||||||
|
if (typeFilter.value !== "all" && String(x.compType) !== typeFilter.value) return false;
|
||||||
|
if (!kw) return true;
|
||||||
|
return [x.name, x.alias, x.code, x.dataCode].some(v => String(v || "").toLowerCase().includes(kw));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragFrom = ref<number | null>(null);
|
||||||
|
const dragTo = ref<number | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadList(), loadSchools()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
items.value = (await UserSettingApi.getWidgetCatalogList()) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 候选池列表拉取失败", e);
|
||||||
|
items.value = [];
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 机构树仅超管可取(后端 getSchTree 校验 type==1);校管视角由本校兜底 */
|
||||||
|
async function loadSchools() {
|
||||||
|
if (!isSuperUser.value) return;
|
||||||
|
try {
|
||||||
|
const tree: any = await UserSettingApi.getSchTree();
|
||||||
|
schools.value = flattenSchools(tree);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 机构树拉取失败", e);
|
||||||
|
schools.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSchools(node: any): { id: number; name: string }[] {
|
||||||
|
const typed: { id: number; name: string }[] = [];
|
||||||
|
const leaves: { id: number; name: string }[] = [];
|
||||||
|
const walk = (n: any) => {
|
||||||
|
if (!n || typeof n !== "object") return;
|
||||||
|
const kids = Array.isArray(n.children) ? n.children : [];
|
||||||
|
if (kids.length > 0) {
|
||||||
|
kids.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = { id: Number(n.id), name: n.name || `学校 ${n.id}` };
|
||||||
|
if (Number(n.type) === 2) typed.push(item);
|
||||||
|
leaves.push(item);
|
||||||
|
};
|
||||||
|
if (Array.isArray(node)) node.forEach(walk);
|
||||||
|
else walk(node);
|
||||||
|
return typed.length > 0 ? typed : leaves;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setView(v: "super" | "school") {
|
||||||
|
if (v === "school" && !isSuperUser.value) return;
|
||||||
|
if (v === view.value) return;
|
||||||
|
view.value = v;
|
||||||
|
Message.info(v === "super" ? "已切换:超管视角" : "已切换:校管理员视角");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
if (!isSuperView.value) return;
|
||||||
|
editItem.value = null;
|
||||||
|
editIsNew.value = true;
|
||||||
|
editTab.value = "cfg";
|
||||||
|
editVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: WidgetCatalog) {
|
||||||
|
editItem.value = item;
|
||||||
|
editIsNew.value = false;
|
||||||
|
editTab.value = "cfg";
|
||||||
|
editVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(item: WidgetCatalog) {
|
||||||
|
editItem.value = item;
|
||||||
|
editIsNew.value = false;
|
||||||
|
editTab.value = "prev";
|
||||||
|
editVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
editVisible.value = false;
|
||||||
|
regVisible.value = false;
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(item: WidgetCatalog) {
|
||||||
|
const next = item.enabled ? 0 : 1;
|
||||||
|
try {
|
||||||
|
await saveOne(item, { enabled: next });
|
||||||
|
item.enabled = next;
|
||||||
|
Message.success(next ? "已启用" : "已下线");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 启停保存失败", e);
|
||||||
|
Message.error(typeof e === "string" ? e : "保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(item: WidgetCatalog) {
|
||||||
|
await MyConfirm({
|
||||||
|
title: "删除组件",
|
||||||
|
msg: `确定删除「${item.name}」(${item.code})吗?`,
|
||||||
|
info: "会一并删除它的可见范围点名与各校覆盖设置,删除后无法恢复。",
|
||||||
|
async onOk() {
|
||||||
|
await UserSettingApi.deleteWidgetCatalog(item.id as number);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(id?: number) {
|
||||||
|
dragFrom.value = id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(id?: number) {
|
||||||
|
if (id !== dragFrom.value) dragTo.value = id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragFrom.value = null;
|
||||||
|
dragTo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽落位:在全量 sorted 顺序里移位,再按 (i+1)*10 重写全部 sort(过滤态也不串序) */
|
||||||
|
async function onDrop(targetId?: number) {
|
||||||
|
const from = dragFrom.value;
|
||||||
|
dragFrom.value = null;
|
||||||
|
dragTo.value = null;
|
||||||
|
if (from == null || targetId == null || from === targetId) return;
|
||||||
|
|
||||||
|
const sorted = [...items.value].sort(
|
||||||
|
(a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0) || (Number(a.id) || 0) - (Number(b.id) || 0)
|
||||||
|
);
|
||||||
|
const fromIdx = sorted.findIndex(x => x.id === from);
|
||||||
|
const toIdx = sorted.findIndex(x => x.id === targetId);
|
||||||
|
if (fromIdx < 0 || toIdx < 0) return;
|
||||||
|
|
||||||
|
const [moved] = sorted.splice(fromIdx, 1);
|
||||||
|
sorted.splice(toIdx, 0, moved);
|
||||||
|
|
||||||
|
const changed: WidgetCatalog[] = [];
|
||||||
|
sorted.forEach((row, i) => {
|
||||||
|
const next = (i + 1) * 10;
|
||||||
|
if ((Number(row.sort) || 0) !== next) {
|
||||||
|
row.sort = next;
|
||||||
|
changed.push(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.value = sorted;
|
||||||
|
if (changed.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of changed) await saveOne(row, {});
|
||||||
|
Message.success("排序已保存");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 排序保存失败", e);
|
||||||
|
Message.error(typeof e === "string" ? e : "排序保存失败");
|
||||||
|
}
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单行保存:超管写全局目录(整行回传,避免 JPA 合并把未传字段置空);
|
||||||
|
* 校管写本校覆盖(schId 由后端取登录校,前端不传)。
|
||||||
|
* 注:校管侧四个覆盖字段恒回传当前生效值,等价于「本校显式固化」,值不产生可见漂移。
|
||||||
|
*/
|
||||||
|
async function saveOne(item: WidgetCatalog, patch: { enabled?: number; sort?: number }) {
|
||||||
|
if (isSuperView.value) {
|
||||||
|
await UserSettingApi.saveWidgetCatalog({ ...item, ...patch });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await UserSettingApi.saveWidgetSchoolSetting({
|
||||||
|
widgetId: item.id,
|
||||||
|
enabled: patch.enabled !== undefined ? patch.enabled : item.enabled,
|
||||||
|
roleTypes: item.roleTypes ?? null,
|
||||||
|
sortNo: patch.sort !== undefined ? patch.sort : item.sort ?? 0,
|
||||||
|
alias: item.alias ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空态引导:把前端注册表里有、候选池里没有的内置键一次登记进目录 */
|
||||||
|
async function quickRegisterBuiltins() {
|
||||||
|
const pending = registryCodes.filter(code => !registeredCodes.value.includes(code));
|
||||||
|
if (pending.length === 0) {
|
||||||
|
Message.info("内置组件已全部登记");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registering.value = true;
|
||||||
|
let base = nextSort.value;
|
||||||
|
let done = 0;
|
||||||
|
for (const code of pending) {
|
||||||
|
try {
|
||||||
|
await UserSettingApi.saveWidgetCatalog({
|
||||||
|
id: null,
|
||||||
|
code,
|
||||||
|
name: code,
|
||||||
|
compType: 1,
|
||||||
|
content: null,
|
||||||
|
dataCode: code,
|
||||||
|
scopeType: 0,
|
||||||
|
scopeSchIds: [],
|
||||||
|
roleTypes: null,
|
||||||
|
roleAlias: null,
|
||||||
|
sort: base,
|
||||||
|
enabled: 1,
|
||||||
|
});
|
||||||
|
done++;
|
||||||
|
base += 10;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 登记失败", code, e);
|
||||||
|
Message.error(typeof e === "string" ? e : `登记失败:${code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registering.value = false;
|
||||||
|
if (done > 0) {
|
||||||
|
Message.success(`已登记 ${done} 个内置组件`);
|
||||||
|
await loadList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "sass:color";
|
||||||
|
@use "../../assets/styles/utils/varible" as *;
|
||||||
|
|
||||||
|
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
|
||||||
|
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
|
||||||
|
|
||||||
|
.widget-manage {
|
||||||
|
--wm-t1: #2c3138;
|
||||||
|
--wm-t2: #4a4f55;
|
||||||
|
--wm-t3: #7a8088;
|
||||||
|
--wm-t4: #9aa0a8;
|
||||||
|
--wm-line: #eef0f3;
|
||||||
|
--wm-line2: #e5e8ee;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm-viewbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.vb-tip {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.role-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $wm-brand;
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: #f0f2f6;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
.sg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover {
|
||||||
|
color: $wm-brand;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background: #fff;
|
||||||
|
color: $wm-brand;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
b {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8a92a0;
|
||||||
|
background: #e8ebf1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
&.active b {
|
||||||
|
color: $wm-brand;
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #f7f9fc;
|
||||||
|
border: 1px dashed var(--wm-line2);
|
||||||
|
color: var(--wm-t3);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
b {
|
||||||
|
color: var(--wm-t2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #eef1f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
&.warn {
|
||||||
|
background: #fffaf3;
|
||||||
|
border-color: #f5dfc0;
|
||||||
|
color: #8a5a17;
|
||||||
|
b {
|
||||||
|
color: #7a4d10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.key {
|
||||||
|
background: #f3fbf6;
|
||||||
|
border-color: #cfe9d9;
|
||||||
|
color: #2c6b47;
|
||||||
|
b {
|
||||||
|
color: #1d5537;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #e4f4ea;
|
||||||
|
color: #1d5537;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tb-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 9px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
height: 30px;
|
||||||
|
width: 210px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px 0 28px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.18s;
|
||||||
|
&:focus {
|
||||||
|
border-color: $wm-brand;
|
||||||
|
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wgrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(252px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.wm-loading {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wcard.add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 168px;
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.16s;
|
||||||
|
.plus {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #c4c9d2;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
border-color: $wm-brand;
|
||||||
|
color: $wm-brand;
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
.plus {
|
||||||
|
color: $wm-brand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 44px 20px 48px;
|
||||||
|
border: 1px dashed var(--wm-line2);
|
||||||
|
border-radius: 10px;
|
||||||
|
.es-ic {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--wm-t1);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
line-height: 1.8;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
code {
|
||||||
|
background: #f2f4f8;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.es-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.es-dim {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
}
|
||||||
|
&.slim {
|
||||||
|
padding: 34px 20px;
|
||||||
|
border: none;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover {
|
||||||
|
border-color: $wm-brand;
|
||||||
|
color: $wm-brand;
|
||||||
|
}
|
||||||
|
&.primary {
|
||||||
|
background: $wm-brand;
|
||||||
|
border-color: $wm-brand;
|
||||||
|
color: #fff;
|
||||||
|
&:hover {
|
||||||
|
background: color.adjust($wm-brand, $lightness: -6%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.sm {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="wm-reg-root">
|
||||||
|
<div class="modal-mask show" @click="close"></div>
|
||||||
|
<div class="modal show sm">
|
||||||
|
<div class="m-head">
|
||||||
|
<div>
|
||||||
|
<h4>登记内置组件 <span class="badge builtin">超管专属</span></h4>
|
||||||
|
<div class="m-sub">键须与 front/src/components/widgets/index.ts 一致</div>
|
||||||
|
</div>
|
||||||
|
<button class="x" @click="close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="m-body">
|
||||||
|
<p>
|
||||||
|
<b>原理:</b>内置组件的<b>代码</b>打在前端工程里,并在注册表 <code>index.ts</code> 注册成一个<b>键</b>;
|
||||||
|
<code>widget_catalog</code> 只存「目录行」(键 + 展示名 + 排序 + 启用 + 范围 + 角色 + 别名覆盖),<b>不存内容</b>。
|
||||||
|
用户端拿到候选池后按 <code>code</code> 去注册表查,命中就渲染对应 Vue 组件。<br />
|
||||||
|
所以「登记」= 往目录表插一条 <code>comp_type=1, code=注册表里的键</code> 的记录,<b>不会让代码里凭空多出一个组件</b>。
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>前端新建 <code>components/widgets/XxxWidget.vue</code></li>
|
||||||
|
<li>在 <code>components/widgets/index.ts</code> 注册 <code>{ code, name }</code>(键一旦发布就别改,改键 = 换身份)</li>
|
||||||
|
<li>需要动态数据时,后端 <code>WidgetDataService</code> 加白名单分支</li>
|
||||||
|
<li><b>发版部署</b>(内置组件必须发版才生效)</li>
|
||||||
|
<li>回到本弹窗点「登记」,或跑 <code>resources/sql/widget_catalog.sql</code> 种子</li>
|
||||||
|
</ol>
|
||||||
|
<ul class="reg-list">
|
||||||
|
<li v-for="row in rows" :key="row.code">
|
||||||
|
<input v-model="row.name" class="inp name-inp" maxlength="30" placeholder="展示名" />
|
||||||
|
<span class="rc">{{ row.code }}</span>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span v-if="row.registered" class="badge ok">已登记</span>
|
||||||
|
<button v-else class="btn sm primary" :disabled="saving" @click="registerOne(row)">登记</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="rows.length === 0" class="empty">前端注册表里没有可登记的键</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="m-foot">
|
||||||
|
<span class="save-tip">{{ tip }}</span>
|
||||||
|
<button class="btn plain" @click="close">关闭</button>
|
||||||
|
<button v-if="pendingCount > 1" class="btn primary" :disabled="saving" @click="registerAll">
|
||||||
|
一键登记 {{ pendingCount }} 个未登记键
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import UserSettingApi from "@/api/UserSettingApi";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
/** 前端注册表的键(来自 components/widgets/index.ts) */
|
||||||
|
registryCodes: string[];
|
||||||
|
/** 已登记进候选池的内置键 */
|
||||||
|
registeredCodes: string[];
|
||||||
|
/** 登记时的起始排序基数 */
|
||||||
|
nextSort: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: "close"): void; (e: "saved"): void }>();
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const tip = ref("");
|
||||||
|
const rows = reactive<{ code: string; name: string; registered: boolean }[]>([]);
|
||||||
|
|
||||||
|
const pendingCount = computed(() => rows.filter(x => !x.registered).length);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
show => {
|
||||||
|
if (!show) return;
|
||||||
|
rows.splice(0, rows.length);
|
||||||
|
props.registryCodes.forEach(code => {
|
||||||
|
rows.push({ code, name: code, registered: props.registeredCodes.includes(code) });
|
||||||
|
});
|
||||||
|
tip.value = "";
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerOne(row: { code: string; name: string }) {
|
||||||
|
await registerBatch([{ code: row.code, name: row.name }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerAll() {
|
||||||
|
const payload = rows.filter(x => !x.registered).map(x => ({ code: x.code, name: x.name }));
|
||||||
|
await registerBatch(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerBatch(payload: { code: string; name: string }[]) {
|
||||||
|
if (payload.length === 0) return;
|
||||||
|
saving.value = true;
|
||||||
|
let done = 0;
|
||||||
|
let baseSort = props.nextSort;
|
||||||
|
for (const one of payload) {
|
||||||
|
try {
|
||||||
|
await UserSettingApi.saveWidgetCatalog({
|
||||||
|
id: null,
|
||||||
|
code: one.code,
|
||||||
|
name: (one.name || one.code).trim(),
|
||||||
|
compType: 1,
|
||||||
|
content: null,
|
||||||
|
dataCode: one.code,
|
||||||
|
scopeType: 0,
|
||||||
|
scopeSchIds: [],
|
||||||
|
roleTypes: null,
|
||||||
|
roleAlias: null,
|
||||||
|
sort: baseSort,
|
||||||
|
enabled: 1,
|
||||||
|
});
|
||||||
|
done++;
|
||||||
|
baseSort += 10;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[widget] 登记失败", one.code, e);
|
||||||
|
Message.error(typeof e === "string" ? e : `登记失败:${one.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = false;
|
||||||
|
if (done > 0) {
|
||||||
|
Message.success(`已登记 ${done} 个内置组件`);
|
||||||
|
tip.value = "登记后进入候选池,可回主界面继续配范围 / 角色";
|
||||||
|
emit("saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "sass:color";
|
||||||
|
@use "../../assets/styles/utils/varible" as *;
|
||||||
|
|
||||||
|
$wm-brand: $color-primary;
|
||||||
|
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
|
||||||
|
|
||||||
|
.wm-reg-root {
|
||||||
|
--wm-t1: #2c3138;
|
||||||
|
--wm-t2: #4a4f55;
|
||||||
|
--wm-t3: #7a8088;
|
||||||
|
--wm-t4: #9aa0a8;
|
||||||
|
--wm-line: #eef0f3;
|
||||||
|
--wm-ok-bg: #e8f7ee;
|
||||||
|
--wm-ok: #12a150;
|
||||||
|
}
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(16, 24, 40, 0.3);
|
||||||
|
z-index: 110;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 780px;
|
||||||
|
max-width: 94vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: 111;
|
||||||
|
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.24);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
&.sm {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 22px 12px;
|
||||||
|
border-bottom: 1px solid var(--wm-line);
|
||||||
|
h4 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wm-t1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.m-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
&:hover {
|
||||||
|
background: #f2f4f8;
|
||||||
|
color: var(--wm-t2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 22px 18px;
|
||||||
|
> p {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
line-height: 1.75;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--wm-t3);
|
||||||
|
line-height: 1.9;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
b {
|
||||||
|
color: var(--wm-t2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 22px 16px;
|
||||||
|
border-top: 1px solid var(--wm-line);
|
||||||
|
.save-tip {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wm-ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
&.builtin {
|
||||||
|
background: $wm-brand-weak;
|
||||||
|
color: $wm-brand;
|
||||||
|
}
|
||||||
|
&.ok {
|
||||||
|
background: var(--wm-ok-bg);
|
||||||
|
color: var(--wm-ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.reg-list {
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--wm-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid var(--wm-line);
|
||||||
|
font-size: 13px;
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
&.empty {
|
||||||
|
color: var(--wm-t4);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.rc {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--wm-t4);
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.inp {
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
&:focus {
|
||||||
|
border-color: $wm-brand;
|
||||||
|
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
|
||||||
|
}
|
||||||
|
&.name-inp {
|
||||||
|
width: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #d0d5dd;
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--wm-t4);
|
||||||
|
}
|
||||||
|
&.primary {
|
||||||
|
background: $wm-brand;
|
||||||
|
border-color: $wm-brand;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
&.sm {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||