Compare commits
34
Commits
71c08d6e9a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73297e4b52 | ||
|
|
4f94dbdbda | ||
|
|
51c95dd7b9 | ||
|
|
c39c0f83d7 | ||
|
|
83a73dfcc8 | ||
|
|
b8c55a0654 | ||
|
|
7cb8b7b974 | ||
|
|
16d49375f1 | ||
|
|
b2a4eeb2f3 | ||
|
|
8bbe3c654b | ||
|
|
e83983b41c | ||
|
|
3605a91b78 | ||
|
|
24ae7b13fe | ||
|
|
1a011656ec | ||
|
|
32b73ba407 | ||
|
|
e8ab9280bf | ||
|
|
d48153ced9 | ||
|
|
d034320406 | ||
|
|
3b0f29b0d1 | ||
|
|
5c7d5d68e4 | ||
|
|
a2aa4f925c | ||
|
|
2dec060b91 | ||
|
|
c12628f051 | ||
|
|
0a93441f64 | ||
|
|
b1dba84c49 | ||
|
|
efd55a3a75 | ||
|
|
5296b5999c | ||
|
|
ec53c34b9c | ||
|
|
e034b947e5 | ||
|
|
e1aee91a98 | ||
|
|
14dcdb7fe1 | ||
|
|
6a2c82c0ef | ||
|
|
0080861482 | ||
|
|
02a203a7f1 |
@@ -1,9 +1,17 @@
|
||||
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
|
||||
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
|
||||
# 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。
|
||||
#
|
||||
# 本地起服务的三种模式(详见 vite.config.ts 的 server.proxy 注释):
|
||||
# 纯前端 mock: VITE_IS_MOCK=true npm run dev → 用户/菜单走 src/assets/mock,其余接口透传给后端
|
||||
# 本地后端联调: npm run dev → 跨源直连本文件 VITE_SERVICE_HOST
|
||||
# 线上后端联调: VITE_SERVICE_HOST= YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
|
||||
# (留空 VITE_SERVICE_HOST 让请求走同源 /api,由 dev 代理转发;CAS 登录 cookie 跨域带不过去时改用本地后端)
|
||||
VITE_BASE_HOST=http://base.bestsch.com
|
||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||
VITE_SERVICE_HOST=http://localhost:8060
|
||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||
VITE_IS_MOCK=false
|
||||
# 主题通道 dev 跨源直连(可选,需通道已开 CORS);留空则同源相对 → 走 vite 的 /static 代理
|
||||
VITE_THEME_HOST=
|
||||
|
||||
@@ -19,7 +19,22 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
set -e
|
||||
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
|
||||
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
|
||||
chosen=""
|
||||
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
|
||||
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
|
||||
chosen="${host}"
|
||||
break
|
||||
fi
|
||||
echo "apk 源不可达: ${host}"
|
||||
done
|
||||
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
|
||||
echo "使用 apk 源: ${chosen}"
|
||||
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,21 +53,29 @@ jobs:
|
||||
- name: 构建前端(node 容器)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f frontbuild 2>/dev/null || true
|
||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." frontbuild:/build/
|
||||
docker exec frontbuild sh -c '
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c '
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
echo "=== dist 产物 ==="
|
||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||
# 从容器 cp dist 到宿主工作目录
|
||||
docker cp frontbuild:/build/dist ./dist
|
||||
docker rm -f frontbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
echo "BUILD CHECK OK"
|
||||
|
||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||
|
||||
+20
@@ -27,6 +27,26 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- 主题通道引导(A 类免插件接入,见 YschTheme《管理端主题远程化与公共静态通道方案》§11):
|
||||
唯一接入点。不装 npm 依赖、不改构建配置;FOUC 门、manifest 预取、主题 <link> 追加与
|
||||
排序(晚于 viewuiplus.css)全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,
|
||||
本应用无需重新构建。
|
||||
origin 零写死,优先级四档:__APP_CONFIG__.staticHost(专用通道覆盖)
|
||||
> __APP_CONFIG__.baseHost(env-config 在线维护,换域名改配置即生效)
|
||||
> %VITE_THEME_HOST%(仅 dev 注入的跨源直连,需通道已开 CORS)
|
||||
> 同源相对 "/static/…"(生产常态:本应用部署在 base 域 /userbase/front/,与通道同源;
|
||||
本地 dev 由 vite.config 的 /static 代理兜到通道)。
|
||||
document.write 是有意为之:保证 parser-blocking,让档位类与 FOUC 门先于首帧;
|
||||
build 不读 .env,占位符留字面量 → 判首字符 % 置空 → 产物零环境值烧入。 -->
|
||||
<script>
|
||||
(function () {
|
||||
var c = window.__APP_CONFIG__ || {};
|
||||
var themeHost = "%VITE_THEME_HOST%";
|
||||
if (themeHost.charAt(0) === "%") themeHost = "";
|
||||
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
|
||||
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
|
||||
})();
|
||||
</script>
|
||||
<title><%- title %></title>
|
||||
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||
<script src="./libs/jquery.min.js"></script>
|
||||
|
||||
Generated
+4
-4
@@ -16,7 +16,7 @@
|
||||
"mockjs": "^1.1.0",
|
||||
"pinia": "^2.1.4",
|
||||
"sass": "^1.64.1",
|
||||
"view-ui-plus": "^1.3.16",
|
||||
"view-ui-plus": "^1.3.24",
|
||||
"vite-plugin-mock": "2.9.6",
|
||||
"vue": "^3.4.19",
|
||||
"vue-router": "4",
|
||||
@@ -8354,9 +8354,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/view-ui-plus": {
|
||||
"version": "1.3.21",
|
||||
"resolved": "https://registry.npmmirror.com/view-ui-plus/-/view-ui-plus-1.3.21.tgz",
|
||||
"integrity": "sha512-WvMd6klBnhgVjxJpNn/8Bpa/+aEWv1Ii5cMKvXMnsTebbOyaM6Qm4hEUBglXA/MPlTfcop7YkGC4eHHM+x3iCg==",
|
||||
"version": "1.3.24",
|
||||
"resolved": "https://registry.npmmirror.com/view-ui-plus/-/view-ui-plus-1.3.24.tgz",
|
||||
"integrity": "sha512-umhDHnCPGIHCQYYyZDw3AfGSQqlUThR4/eEZjtzCFrrjLzXpE6I8eIggzZQGKHK3zKzRTopDYJsyeFb+K9MLuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async-validator": "^3.3.0",
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"mockjs": "^1.1.0",
|
||||
"pinia": "^2.1.4",
|
||||
"sass": "^1.64.1",
|
||||
"view-ui-plus": "^1.3.16",
|
||||
"view-ui-plus": "^1.3.24",
|
||||
"vite-plugin-mock": "2.9.6",
|
||||
"vue": "^3.4.19",
|
||||
"vue-router": "4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+1
-1
@@ -70,7 +70,7 @@ const setCurrentRole = (user: any) => {
|
||||
};
|
||||
localStorage.setItem("currentRole", encodeURI(JSON.stringify(currentRole)));
|
||||
|
||||
// 主题样式已本地化到 src/assets/styles/layout.css(含 default-theme / base.layout.beta / _iview_override),不再请求远程样式
|
||||
// 主题样式由中央静态通道下发(index.html 的 yh-theme-init.js 挂 /static/*),本仓不再持有 layout.css
|
||||
|
||||
if (user?.type === 1) {
|
||||
store.setUserRole(UserRoleEnum.SuperAdmin);
|
||||
|
||||
@@ -30,8 +30,28 @@ export default {
|
||||
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
|
||||
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
|
||||
'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{id}'],
|
||||
'SysGroup.disable': ['POST', '/api/sysgroup/Disable/{id}'],
|
||||
'SysGroup.enable': ['POST', '/api/sysgroup/Enable/{id}'],
|
||||
'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'],
|
||||
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
|
||||
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
|
||||
'CustomDeptCategory.customDeptCategoryList': ['GET', '/api/deptcategory/List'],
|
||||
'CustomDeptCategory.customDeptCategoryAdd': ['POST', '/api/deptcategory/Add'],
|
||||
'CustomDeptCategory.customDeptCategoryEdit': ['POST', '/api/deptcategory/Edit/{id}'],
|
||||
'CustomDeptCategory.customDeptCategorySort': ['POST', '/api/deptcategory/Sort'],
|
||||
'CustomDeptCategory.customDeptCategoryDelete': ['POST', '/api/deptcategory/Delete/{id}'],
|
||||
'CustomDept.customDeptList': ['GET', '/api/customdept/List'],
|
||||
'CustomDept.customDeptAdd': ['POST', '/api/customdept/Add'],
|
||||
'CustomDept.customDeptEdit': ['POST', '/api/customdept/Edit/{id}'],
|
||||
'CustomDept.customDeptMove': ['POST', '/api/customdept/Move/{id}'],
|
||||
'CustomDept.customDeptSort': ['POST', '/api/customdept/Sort'],
|
||||
'CustomDept.customDeptDisable': ['POST', '/api/customdept/Disable/{id}'],
|
||||
'CustomDept.customDeptEnable': ['POST', '/api/customdept/Enable/{id}'],
|
||||
'CustomDept.customDeptDelete': ['POST', '/api/customdept/Delete/{id}'],
|
||||
'CustomDept.customDeptMembers': ['GET', '/api/customdept/Members/{id}'],
|
||||
'CustomDept.customDeptCandidateUsers': ['GET', '/api/customdept/CandidateUsers/{id}'],
|
||||
'CustomDept.customDeptAddMembers': ['POST', '/api/customdept/AddMembers/{id}'],
|
||||
'CustomDept.customDeptRemoveMembers': ['POST', '/api/customdept/RemoveMembers/{id}'],
|
||||
'PushSubscription.test': ['POST', '/api/push/Test'],
|
||||
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
||||
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
||||
|
||||
Vendored
+629
@@ -148,6 +148,59 @@ export interface SysGroupRequest {
|
||||
type: number;
|
||||
sort?: number;
|
||||
}
|
||||
export interface SysGroupIncludeChildrenRequest {
|
||||
includeChildren: boolean;
|
||||
}
|
||||
export interface CustomDeptCategoryRequest {
|
||||
schoolId?: number;
|
||||
name: string;
|
||||
}
|
||||
export interface CustomDeptCategorySortRequest {
|
||||
schoolId?: number;
|
||||
ids: number[];
|
||||
}
|
||||
export interface CustomDeptCategoryVo {
|
||||
id?: number;
|
||||
schoolId?: number;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
/** 该分类下有效部门数;>0 时分类不允许删除 */
|
||||
departmentCount?: number;
|
||||
}
|
||||
export interface CustomDepartmentRequest {
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
name: string;
|
||||
}
|
||||
export interface CustomDepartmentMoveRequest {
|
||||
schoolId?: number;
|
||||
categoryId: number;
|
||||
}
|
||||
export interface CustomDepartmentSortRequest {
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
ids: number[];
|
||||
}
|
||||
export interface CustomDepartmentMemberRequest {
|
||||
schoolId?: number;
|
||||
userIds: number[];
|
||||
}
|
||||
export interface CustomDepartmentMemberVo {
|
||||
userId?: number;
|
||||
account?: string;
|
||||
name?: string;
|
||||
}
|
||||
export interface CustomDepartmentVo {
|
||||
id?: number;
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
/** true=启用 / false=停用(后端 CUSTOM 侧统一输出 boolean) */
|
||||
enabled?: boolean;
|
||||
memberCount?: number;
|
||||
}
|
||||
export interface PushTestRequest {
|
||||
appId?: number;
|
||||
interfacePath?: string;
|
||||
@@ -223,6 +276,8 @@ export interface ApplicationInfoRequest {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
desc?: string;
|
||||
appCode?: string;
|
||||
appNature?: string;
|
||||
clients?: number[];
|
||||
pcUrl?: string;
|
||||
pcIcon?: string;
|
||||
@@ -230,6 +285,7 @@ export interface ApplicationInfoRequest {
|
||||
appIcon?: string;
|
||||
officialAccountUrl?: string;
|
||||
dingtalkUrl?: string;
|
||||
miniprogramUrl?: string;
|
||||
}
|
||||
export interface SysUserMoveRequest {
|
||||
userId: number;
|
||||
@@ -241,6 +297,14 @@ export interface SysGroupVo {
|
||||
name?: string;
|
||||
type?: number;
|
||||
sort?: number;
|
||||
/** 启停状态:0=启用 / 1=停用(存量 null 按启用处理) */
|
||||
status?: number;
|
||||
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
|
||||
includeChildren?: number;
|
||||
/** 直属成员数(部门表格「人员」列;与服务端 SysGroupVo 同步,api:gen 后一样保留) */
|
||||
memberCount?: number;
|
||||
/** 直属成员姓名预览(最多 50 个),完整名单走 /api/sysuser/List?groupId= */
|
||||
memberNames?: string[];
|
||||
children?: SysGroupVo[];
|
||||
}
|
||||
export interface AppItem {
|
||||
@@ -766,6 +830,8 @@ export interface ApplicationInfoVo {
|
||||
desc?: string;
|
||||
appKey?: number;
|
||||
appSecret?: string;
|
||||
appCode?: string;
|
||||
appNature?: string;
|
||||
clients?: number[];
|
||||
pcUrl?: string;
|
||||
pcIcon?: string;
|
||||
@@ -773,6 +839,7 @@ export interface ApplicationInfoVo {
|
||||
appIcon?: string;
|
||||
officialAccountUrl?: string;
|
||||
dingtalkUrl?: string;
|
||||
miniprogramUrl?: string;
|
||||
}
|
||||
export interface BasePageResultApplicationInfoVo {
|
||||
total?: number;
|
||||
@@ -1532,6 +1599,81 @@ declare global {
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'SysGroup.sysGroupRemove', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/sysgroup/Disable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
disable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'SysGroup.disable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/sysgroup/Enable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
enable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'SysGroup.enable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/sysgroup/SetIncludeChildren/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **RequestBody**
|
||||
* ```ts
|
||||
* type RequestBody = SysGroupIncludeChildrenRequest
|
||||
* ```
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
setIncludeChildren<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: SysGroupIncludeChildrenRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'SysGroup.setIncludeChildren', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
@@ -1699,6 +1841,493 @@ declare global {
|
||||
config?: Config
|
||||
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>;
|
||||
};
|
||||
CustomDeptCategory: {
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/deptcategory/List
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* schoolId?: number
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDeptCategoryVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryList<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo[]> & {
|
||||
params?: {
|
||||
schoolId?: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo[], 'CustomDeptCategory.customDeptCategoryList', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Add
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategoryRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDeptCategoryVo
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryAdd<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||
data: CustomDeptCategoryRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryAdd', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Edit/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategoryRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDeptCategoryVo
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryEdit<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDeptCategoryRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryEdit', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Sort
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategorySortRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptCategorySort<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
data: CustomDeptCategorySortRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDeptCategory.customDeptCategorySort', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Delete/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryDelete<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDeptCategory.customDeptCategoryDelete', Config>;
|
||||
};
|
||||
CustomDept: {
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/List
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* categoryId: number
|
||||
* schoolId?: number
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptList<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo[]> & {
|
||||
params: {
|
||||
categoryId: number;
|
||||
schoolId?: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo[], 'CustomDept.customDeptList', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Add
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptAdd<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
data: CustomDepartmentRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptAdd', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Edit/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptEdit<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptEdit', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Move/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMoveRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptMove<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMoveRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptMove', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Sort
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentSortRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptSort<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
data: CustomDepartmentSortRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptSort', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Disable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptDisable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptDisable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Enable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptEnable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptEnable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Delete/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptDelete<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptDelete', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/Members/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentMemberVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptMembers<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptMembers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/CandidateUsers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* keyword?: string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentMemberVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptCandidateUsers<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
params?: {
|
||||
keyword?: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptCandidateUsers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/AddMembers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMemberRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptAddMembers<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMemberRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptAddMembers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/RemoveMembers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMemberRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptRemoveMembers<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMemberRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptRemoveMembers', Config>;
|
||||
};
|
||||
PushSubscription: {
|
||||
/**
|
||||
* ---
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"prefix": "appicon",
|
||||
"info": {
|
||||
"name": "AppIcon",
|
||||
"total": 2,
|
||||
"author": "ysch front-end team",
|
||||
"license": {
|
||||
"title": "Remix Icon (Apache-2.0) 等,见 meta.json source",
|
||||
"spdx": "Apache-2.0"
|
||||
},
|
||||
"height": 1024
|
||||
},
|
||||
"icons": {
|
||||
"tongzhi": {
|
||||
"body": "<path d=\"M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z\" ></path>",
|
||||
"cn": "通知"
|
||||
},
|
||||
"wenjuan": {
|
||||
"body": "<path d=\"M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z\" ></path>",
|
||||
"cn": "问卷"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { UserRoleEnum } from "@/types";
|
||||
* 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
|
||||
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
|
||||
* 4. 日志管理 —— 仅超管(父目录)
|
||||
* 5. 基础设置 —— 非业务域的基础配置合集(当前:部门管理),后续字典/参数/模板等杂项归此
|
||||
*
|
||||
* 菜单不渲染图标:本工程侧边栏只输出文字,节点无需 icon 字段。
|
||||
*
|
||||
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
||||
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
||||
@@ -64,6 +67,18 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
|
||||
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
|
||||
],
|
||||
},
|
||||
// 5. 基础设置(父目录):非业务域的基础配置杂项合集,部门管理从「权限管理」迁入
|
||||
{
|
||||
id: 6,
|
||||
modId: 1115,
|
||||
name: "基础设置",
|
||||
page: "",
|
||||
childState: true,
|
||||
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School],
|
||||
children: [
|
||||
{ id: 61, modId: 1106, name: "部门管理", page: "/dept-manage", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
|
||||
],
|
||||
},
|
||||
];
|
||||
return Promise.resolve(filterMenuByRole(menu, userRole));
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 701 B |
File diff suppressed because one or more lines are too long
@@ -98,20 +98,88 @@ h4.subtitle {
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||
.table-spin-load {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
padding-top: 60px;
|
||||
background-image: url(../image/loading.gif);
|
||||
background-size: 60px 60px;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.table-spin-load .ysch-spin {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-left: -30px;
|
||||
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
|
||||
}
|
||||
|
||||
.ivu-table-cell-tree {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ================= 表格操作列(utils/tableActions.ts)=================
|
||||
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
|
||||
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
|
||||
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
|
||||
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
|
||||
.op-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
|
||||
.operating-link {
|
||||
padding-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
|
||||
.op-more {
|
||||
.op-more-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.op-more-arrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多」菜单内危险项(红色 + hover 红底,与 text-error 同源 $color-error) */
|
||||
.ivu-dropdown-item.op-item-danger {
|
||||
color: $color-error !important;
|
||||
|
||||
&:hover {
|
||||
color: $color-error !important;
|
||||
background-color: rgba(232, 104, 74, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* UserBase 专项(不上收通道,勿写入接入规范):左侧树卡统一 300px——
|
||||
通道默认 240 装不下组树操作钮与节点名,本端树页多、统一加宽 */
|
||||
.base-wrap-main .tree-wrapper .tree-container {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
/* 描边徽标(对齐 UserHome「当前生效」样式):view-ui-plus 预设色 Tag 的 border prop
|
||||
渲染仍是实心块(无效 prop),改自定义类挂主题令牌——主态=brand 描边,muted=中性灰描边 */
|
||||
.tag-outline {
|
||||
color: var(--brand) !important;
|
||||
border: 1px solid var(--brand) !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.tag-outline.tag-outline-muted {
|
||||
color: #999 !important;
|
||||
border: 1px solid #dcdee2 !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="appicon-picker">
|
||||
<!-- 当前选中态 / 清除 -->
|
||||
<div class="picker-head">
|
||||
<div class="picked" v-if="modelValue">
|
||||
<span class="picked-glyph scene-tile" v-html="svgOf(modelValue)"></span>
|
||||
<span class="picked-info">
|
||||
<span class="picked-cn">{{ cnOf(modelValue) }}</span>
|
||||
<span class="picked-key">{{ modelValue }}</span>
|
||||
</span>
|
||||
<Icon type="ios-close" class="picked-clear" title="清除(回到位图/默认回退链)" @click.stop="clearPick" />
|
||||
</div>
|
||||
<Button v-else size="small" icon="md-add" @click="open = true">选择矢量图标</Button>
|
||||
<Button v-if="modelValue" size="small" class="ml-8" @click="open = true">更换</Button>
|
||||
</div>
|
||||
|
||||
<!-- 着色:不指定 = 端侧默认(PC 白色 / App 分组色) -->
|
||||
<div class="color-row" v-if="modelValue">
|
||||
<span class="color-label">着色:</span>
|
||||
<Tooltip content="空 = 跟随端侧场景默认(PC 白图标、App 分组色),一般无需指定" placement="top" transfer>
|
||||
<span class="help-icon">?</span>
|
||||
</Tooltip>
|
||||
<div class="swatches">
|
||||
<div class="swatch swatch-none" :class="{ active: !color }" title="端侧默认" @click="pickColor('')">无</div>
|
||||
<div v-for="c in PRESET_COLORS" :key="c" class="swatch" :class="{ active: color === c }"
|
||||
:style="{ background: c }" :title="c" @click="pickColor(c)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="open" title="选择矢量图标(AppIcon 资产库)" width="640" footer-hide transfer :mask-closable="true">
|
||||
<div class="picker-body">
|
||||
<Input v-model="keyword" prefix="ios-search" placeholder="搜索中文名或图标名" clearable />
|
||||
<div class="picker-grid">
|
||||
<div v-for="item in filtered" :key="item.key" class="picker-cell"
|
||||
:class="{ active: item.key === modelValue }" @click="pick(item.key)">
|
||||
<span class="scene-tile" v-html="svgOf(item.key)"></span>
|
||||
<div class="cell-cn">{{ item.cn }}</div>
|
||||
<div class="cell-key">{{ item.key }}</div>
|
||||
</div>
|
||||
<div class="picker-empty" v-if="!filtered.length">无匹配图标(清单未拉取成功时仅剩内置副本)</div>
|
||||
</div>
|
||||
<div class="picker-tip">
|
||||
三场景同形:方块底(PC 应用卡)/ 圆底(App 首页)/ 线性(App 列表)——底色由各端场景决定,此处仅选形状。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import builtinManifest from "@/assets/appicon-icons.json";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
/**
|
||||
* 矢量图标选择器(AppIcon 资产库)。
|
||||
* 清单来源:可配置 manifestUrl(默认线上 /static/appicon/icons.json),拉取失败回落本地内置副本
|
||||
* (P1 静态应用未部署时管理端依然可用;副本与资产库 icons.json 同步,见资产库 build_dist)。
|
||||
* 预览直接内联 svg body(不依赖字体加载),tile/circle/plain 三场景同形。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 矢量图标键(AppIcon 图标名),空 = 不使用矢量图标 */
|
||||
modelValue?: string;
|
||||
/** 着色 CSS 色值,空 = 端侧默认 */
|
||||
color?: string;
|
||||
/** 清单地址(默认平台静态通道) */
|
||||
manifestUrl?: string;
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
color: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:color']);
|
||||
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
interface IconEntry { key: string; cn: string; body: string }
|
||||
|
||||
const open = ref(false);
|
||||
const keyword = ref('');
|
||||
const remoteIcons = ref<IconEntry[] | null>(null);
|
||||
|
||||
const PRESET_COLORS = ['#366ef4', '#3fa45b', '#f2994a', '#7c5cc4', '#17b8a6', '#ff5757'];
|
||||
|
||||
const builtinIcons: IconEntry[] = Object.entries((builtinManifest as any).icons || {}).map(
|
||||
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
|
||||
);
|
||||
|
||||
const icons = computed<IconEntry[]>(() => remoteIcons.value ?? builtinIcons);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
if (!kw) return icons.value;
|
||||
return icons.value.filter(x => x.cn.includes(kw) || x.key.includes(kw));
|
||||
});
|
||||
|
||||
function cnOf(key: string): string {
|
||||
return icons.value.find(x => x.key === key)?.cn || key;
|
||||
}
|
||||
|
||||
function bodyOf(key: string): string {
|
||||
return icons.value.find(x => x.key === key)?.body || '';
|
||||
}
|
||||
|
||||
function svgOf(key: string): string {
|
||||
const body = bodyOf(key);
|
||||
if (!body) return '';
|
||||
return `<svg viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="width:1em;height:1em;display:block">${body}</svg>`;
|
||||
}
|
||||
|
||||
function pick(key: string) {
|
||||
emit('update:modelValue', key);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function clearPick() {
|
||||
emit('update:modelValue', '');
|
||||
emit('update:color', '');
|
||||
}
|
||||
|
||||
function pickColor(c: string) {
|
||||
emit('update:color', c);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 线上清单优先(P1 部署后自动生效);失败静默回落内置副本
|
||||
try {
|
||||
const url = props.manifestUrl || (($conf?.baseHost || '') + '/static/appicon/icons.json');
|
||||
const res = await fetch(url, { credentials: 'omit' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list = Object.entries(data?.icons || {}).map(
|
||||
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
|
||||
);
|
||||
if (list.length) remoteIcons.value = list;
|
||||
} catch {
|
||||
// 回落内置副本,不提示(本地开发 baseHost 指向本地时必然 404)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.appicon-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
|
||||
.picked-cn {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.picked-key {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-family: Consolas, monospace;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.picked-clear {
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picked-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
|
||||
.color-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid #c5c8ce;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
color: #808695;
|
||||
cursor: help;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
border-color: #577afb;
|
||||
box-shadow: 0 0 0 1px #577afb;
|
||||
}
|
||||
|
||||
&.swatch-none {
|
||||
background: #fff;
|
||||
border: 1px dashed #c5c8ce;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #808695;
|
||||
|
||||
&.active {
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picker-body {
|
||||
.picker-grid {
|
||||
margin-top: 12px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker-cell {
|
||||
text-align: center;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #577afb;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #577afb;
|
||||
box-shadow: 0 0 0 1px #577afb;
|
||||
}
|
||||
|
||||
.cell-cn {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cell-key {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
grid-column: 1 / -1;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.picker-tip {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 选中态/网格预览的方块场景(与端侧 tile token 同构,仅管理端演示用) */
|
||||
.scene-tile {
|
||||
--appicon-bg: #366ef4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 18%;
|
||||
background: var(--appicon-bg);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -215,6 +215,11 @@ function getNodeById(id: number) {
|
||||
return refMyTree.value?.getNodeById(id);
|
||||
}
|
||||
|
||||
/** 在指定节点后追加自定义控件(见 MyTree.attachNodeControl) */
|
||||
function attachNodeControl(id: number, className: string, html: string) {
|
||||
return refMyTree.value?.attachNodeControl(id, className, html);
|
||||
}
|
||||
|
||||
function getCheckedNodes() {
|
||||
return refMyTree.value?.getCheckedNodes() || [];
|
||||
}
|
||||
@@ -257,6 +262,7 @@ defineExpose({
|
||||
removeNode,
|
||||
renameNode,
|
||||
getNodeById,
|
||||
attachNodeControl,
|
||||
getCheckedNodes,
|
||||
checkNodeById,
|
||||
addNote,
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
<template>
|
||||
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
|
||||
<!-- 确认框固定最高层:可能从任意 transfer 弹窗/抽屉(含手动抬层的 3000 抽屉)里唤起,
|
||||
而 Modal 默认层级是全局计数器 1000+N,会被高 z-index 抽屉盖住 -->
|
||||
<Modal v-model="isModal" @on-visible-change="showChange" :title="title" :z-index="4000" transfer>
|
||||
<Alert type="warning" class="mb-1" show-icon>
|
||||
<template #icon>
|
||||
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
|
||||
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" :size="28"></Icon>
|
||||
</template>
|
||||
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
||||
<template #desc v-if="info" slot="desc">{{ info }}</template>
|
||||
<template #desc v-if="info">{{ info }}</template>
|
||||
</Alert>
|
||||
<div v-if="confirmName" class="confirm-name-block mb-1">
|
||||
<div class="mb-5">
|
||||
此操作<span style="font-weight: bold">无法</span>被回滚。<br />
|
||||
请输入 <span class="confirm-name-text">{{ confirmName }}</span> 以确认:
|
||||
</div>
|
||||
<Input v-model="inputName" placeholder="请输入名称以确认" @on-enter="onOkClick" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||
<Button :type="danger ? 'error' : 'primary'" :disabled="!nameMatched" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Modal, Button, Alert, Icon } from "view-ui-plus";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Modal, Button, Alert, Icon, Input } from "view-ui-plus";
|
||||
|
||||
const isModal = ref(false),
|
||||
okLoading = ref(false),
|
||||
cancleLoading = ref(false);
|
||||
cancleLoading = ref(false),
|
||||
inputName = ref("");
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -30,6 +40,8 @@ const props = withDefaults(
|
||||
isLoading?: boolean;
|
||||
confirmText?: string;
|
||||
closeText?: string;
|
||||
confirmName?: string;
|
||||
danger?: boolean;
|
||||
confirmFn: any;
|
||||
closeFn: any;
|
||||
destroy: Function;
|
||||
@@ -39,9 +51,13 @@ const props = withDefaults(
|
||||
isLoading: true,
|
||||
confirmText: "确认",
|
||||
closeText: "取消",
|
||||
confirmName: "",
|
||||
danger: false,
|
||||
}
|
||||
);
|
||||
|
||||
const nameMatched = computed(() => !props.confirmName || inputName.value.trim() === props.confirmName);
|
||||
|
||||
onMounted(function () {
|
||||
setTimeout(() => {
|
||||
isModal.value = true;
|
||||
@@ -49,6 +65,7 @@ onMounted(function () {
|
||||
});
|
||||
|
||||
const onOkClick = async () => {
|
||||
if (!nameMatched.value) return;
|
||||
if (!!props.isLoading) okLoading.value = true;
|
||||
const { onOk, reslove } = props.confirmFn;
|
||||
await Promise.resolve(onOk());
|
||||
@@ -73,4 +90,15 @@ const showChange = (state: boolean) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
<style lang="scss" scoped>
|
||||
.confirm-name-block {
|
||||
padding: 0 2px;
|
||||
line-height: 1.6;
|
||||
|
||||
.confirm-name-text {
|
||||
color: #ed4014;
|
||||
font-weight: bold;
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { MyConfirm } from "@/types/Basics";
|
||||
import confirm from "./Confirm.vue";
|
||||
|
||||
import { createApp } from "vue";
|
||||
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
|
||||
return new Promise<boolean>((reslove, reject) => {
|
||||
export default ({ title, msg, info, confirmText, closeText, isLoading, confirmName, danger, onOk, onCancel }: MyConfirm) => {
|
||||
return new Promise<boolean>((reslove, _reject) => {
|
||||
const instance = createApp(confirm, {
|
||||
title,
|
||||
msg,
|
||||
@@ -11,6 +11,8 @@ export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onC
|
||||
confirmText,
|
||||
closeText,
|
||||
isLoading,
|
||||
confirmName,
|
||||
danger,
|
||||
confirmFn: {
|
||||
onOk,
|
||||
reslove,
|
||||
|
||||
@@ -222,6 +222,33 @@ export default {
|
||||
if (on) a.addClass('ztree-search-highlight');
|
||||
else a.removeClass('ztree-search-highlight');
|
||||
},
|
||||
/**
|
||||
* 给指定节点追加自定义 DOM 控件(渲染在节点文字之后)。
|
||||
* 用途:在 zTree 节点行尾挂业务控件(如开关),不污染 node.name、
|
||||
* 也不触发节点选中(zTree 的 onClick 绑定在节点 a 标签上,控件在其外部)。
|
||||
* 树数据重载(initTree)后需重新挂载。
|
||||
* @param {*} id 节点 id
|
||||
* @param {string} className 控件容器类名(同时作为去重标识)
|
||||
* @param {string} html 控件 HTML
|
||||
* @returns {HTMLElement|null} 控件元素,失败返回 null
|
||||
*/
|
||||
attachNodeControl(id, className, html) {
|
||||
const _domId = this.domId;
|
||||
const treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
if (!treeObj || !id || !className) return null;
|
||||
const node = treeObj.getNodesByFilter(n => n.id == id, true);
|
||||
if (!node) return null;
|
||||
const li = document.getElementById(node.tId);
|
||||
if (!li) return null;
|
||||
li.querySelectorAll("." + className).forEach(el => el.remove());
|
||||
const box = document.createElement("span");
|
||||
box.className = className;
|
||||
box.innerHTML = html;
|
||||
const a = document.getElementById(node.tId + "_a");
|
||||
if (a && a.parentNode) a.parentNode.insertBefore(box, a.nextSibling);
|
||||
else li.appendChild(box);
|
||||
return box;
|
||||
},
|
||||
clearHighlight() {
|
||||
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||
this.highlightNodes = [];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="page-panel">
|
||||
<div class="fl align-middle">
|
||||
<div class="fl ml-15 align-middle">
|
||||
<slot :checkList="selectList" />
|
||||
</div>
|
||||
|
||||
@@ -21,8 +23,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
import Utils from '@/utils'
|
||||
import { computed, ref, useSlots, watch } from 'vue'
|
||||
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const slots = useSlots()
|
||||
|
||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
||||
const regionRef = ref<HTMLElement | null>(null)
|
||||
const measuredH = useElementHeight(regionRef)
|
||||
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||
|
||||
const selectList = ref<number[]>([])
|
||||
@@ -68,18 +73,30 @@ const {
|
||||
total: res => props.formatResponse(res).total,
|
||||
data: res => props.formatResponse(res).list,
|
||||
initialPage: 1,
|
||||
initialPageSize: 10,
|
||||
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||
immediate: props.autoLoad,
|
||||
}
|
||||
)
|
||||
|
||||
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||
watch(resp, () => {
|
||||
if (selectList.value.length) {
|
||||
selectList.value = []
|
||||
emit('selection-change', selectList.value)
|
||||
}
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
if (!resp.value) return []
|
||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||
})
|
||||
|
||||
const onPageChange = (p: number) => (page.value = p)
|
||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
||||
const onSizeChange = (s: number) => {
|
||||
pageSize.value = s
|
||||
localStorage.setItem('customPageCount', String(s))
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
page.value = 1
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||
src?: string | null;
|
||||
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||
name?: string | null;
|
||||
/** 直径 px;font-size = round(size * 0.44) */
|
||||
size?: number;
|
||||
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||
placement?: "light" | "brand";
|
||||
}>(),
|
||||
{ src: null, name: null, size: 28, placement: "light" },
|
||||
);
|
||||
|
||||
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||
/* 外观偏好异步补齐(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:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||
const showText = computed(() => style.value === "text" && !!text.value);
|
||||
|
||||
const rootClass = computed(() => [
|
||||
"yh-avatar",
|
||||
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||
{ "is-text": showText.value },
|
||||
]);
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="rootClass" :style="rootStyle">
|
||||
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||
<template v-else-if="showText">{{ text }}</template>
|
||||
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||
<circle cx="512" cy="399" r="174" />
|
||||
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.yh-avatar {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.yh-avatar__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||
.yh-avatar__sil {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
fill: currentColor;
|
||||
}
|
||||
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||
.yh-avatar--light {
|
||||
background: var(--brand, #577afb);
|
||||
color: #fff;
|
||||
}
|
||||
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||
.yh-avatar--brand {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
.yh-avatar--brand.is-text {
|
||||
background: #fff;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
</style>
|
||||
+29
-16
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, onBeforeMount, watch, toRefs, nextTick, computed } from "vue";
|
||||
import { ref, inject, onBeforeMount, onBeforeUnmount, onMounted, watch, toRefs, nextTick, computed } from "vue";
|
||||
import { LayoutData } from "@/types/Basics";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||
let props = defineProps<{ layoutData: LayoutData }>();
|
||||
const reload: Function = inject("reload") || new Function("console.error('empty')");
|
||||
|
||||
@@ -12,6 +14,27 @@ const appPanel = ref();
|
||||
let route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
|
||||
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
|
||||
const logoUrl = ref<string>(defaultLogo);
|
||||
const readLogoUrl = () => {
|
||||
let v: string | null = null;
|
||||
try {
|
||||
v = localStorage.getItem("yh-logo-url");
|
||||
} catch {
|
||||
v = null;
|
||||
}
|
||||
logoUrl.value = v || defaultLogo;
|
||||
};
|
||||
const onLogoError = () => {
|
||||
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||
};
|
||||
onMounted(() => {
|
||||
readLogoUrl();
|
||||
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||
|
||||
const isPanel = ref(false),
|
||||
currPath = ref<string>(""),
|
||||
navPath = ref<any[]>([]);
|
||||
@@ -138,7 +161,8 @@ const linkMenu = (page: string) => {
|
||||
};
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = baseHost + "/v/userinfo";
|
||||
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
|
||||
window.location.href = baseHost + "/userhome/front/index.html#/settings?pane=profile";
|
||||
};
|
||||
const logout = () => {
|
||||
let url = window.location.href;
|
||||
@@ -165,16 +189,13 @@ function linkHomePage() {
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="首页" class="icon-btn home-btn" @click="linkHomePage">
|
||||
<SvgIcon name="shouye" :size="22" color="#fff" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
@@ -269,12 +290,4 @@ function linkHomePage() {
|
||||
.header-aside .logout svg {
|
||||
display: block;
|
||||
}
|
||||
.home-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.home-btn svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-2
@@ -13,8 +13,9 @@ import mittBus from "@/utils/mitt";
|
||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||
|
||||
import "@/assets/styles/main.scss";
|
||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
||||
import "@/assets/styles/layout.css";
|
||||
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,
|
||||
// 天然排在本行组件库样式与 main.scss 之后(原本地 layout.css 是通道旧版全量拷贝,已删)
|
||||
//导入自定义指令
|
||||
import directive from "@/utils/directive/index";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import PermissionView from "@/views/PermissionView.vue";
|
||||
import OperationLogList from "@/views/oplog/OperationLogList.vue";
|
||||
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
|
||||
import PushLog from "@/views/push/PushLog.vue";
|
||||
import DeptManage from "@/views/dept-manage/index.vue";
|
||||
import NoAuth from "@/views/NoAuth.vue";
|
||||
|
||||
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
|
||||
@@ -105,6 +106,15 @@ const routes: RouteRecordRaw[] = [
|
||||
component: PermissionView,
|
||||
meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN },
|
||||
},
|
||||
// ===== 部门管理(2026-10-20 部门线 P0 最小批,2026-10-21 迁入「基础设置」分组)=====
|
||||
// 原挂「权限管理」(modId 1108);部门属于组织基础配置而非权限,改挂「基础设置」(modId 1115)
|
||||
// 无删除/成员/职务/新建入口——建部门=建组回「组,用户管理」
|
||||
{
|
||||
path: "/dept-manage",
|
||||
name: "DeptManage",
|
||||
component: DeptManage,
|
||||
meta: { title: "部门管理", icon: "icon-list", modId: 1106, parentId: 1115, roles: ALL_ADMIN },
|
||||
},
|
||||
{
|
||||
path: "/push-log",
|
||||
name: "PushLog",
|
||||
|
||||
Vendored
+4
@@ -105,6 +105,10 @@ export interface MyConfirm {
|
||||
isLoading?: boolean;
|
||||
confirmText?: string;
|
||||
closeText?: string;
|
||||
/** 输名确认:值与 msg 对象名一致才允许点确认键(危险操作删除模,对齐 projman) */
|
||||
confirmName?: string;
|
||||
/** 红色确认键(危险操作) */
|
||||
danger?: boolean;
|
||||
onOk?: Function;
|
||||
onCancel?: Function;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ function closeLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = conf.serviceHost;
|
||||
/**
|
||||
* mock 模式必须短路成同源相对路径,否则 alova 会带着 serviceHost(本地 8060 / 线上域)跨源直连,
|
||||
* vite-plugin-mock 的中间件根本拦不到——这正是 `VITE_IS_MOCK=true` 开了却仍报 "Failed to fetch" 的根因
|
||||
* (axios 那条老链路的 api.config.ts 认 isMock,迁到 alova 后这一处漏了)。
|
||||
* 同源之后:mock 注册过的接口(/api/base/GetUserInfo、GetUserMod)由 mock 应答,
|
||||
* 未注册的接口继续经 vite.config 的 /api 代理透传给后端(见 YH_API_PROXY_TARGET)。
|
||||
*/
|
||||
const baseUrl = conf.isMock ? "" : conf.serviceHost;
|
||||
|
||||
/* 创建带 View Design UI 的 Alova 实例 */
|
||||
export const alovaInstance = createBschAlova(baseUrl, {
|
||||
|
||||
@@ -19,7 +19,8 @@ function closeLoading() {
|
||||
}
|
||||
|
||||
// const baseUrl = conf.serviceHost + "/api";
|
||||
const baseUrl = conf.serviceHost + "/mobiapi";
|
||||
// 同 alova-iview:mock 模式短路成同源,否则 vite-plugin-mock / dev 代理都拦不到跨源请求
|
||||
const baseUrl = (conf.isMock ? "" : conf.serviceHost) + "/mobiapi";
|
||||
/* 创建带 Vant UI 的 Alova 实例 */
|
||||
export const alovaInstance = createBschAlova(baseUrl, {
|
||||
injectHeaders(method) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||
* > "text"(系统默认)
|
||||
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||
*/
|
||||
|
||||
export type AvatarStyle = "text" | "silhouette";
|
||||
|
||||
interface StorageLike {
|
||||
getItem(k: string): string | null;
|
||||
setItem(k: string, v: string): void;
|
||||
removeItem(k: string): void;
|
||||
}
|
||||
|
||||
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||
|
||||
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||
|
||||
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||
|
||||
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||
|
||||
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||
}
|
||||
|
||||
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||
function readAvatarOverride(): AvatarStyle | null {
|
||||
try {
|
||||
const search = (globalThis.location && globalThis.location.search) || "";
|
||||
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||
} catch {
|
||||
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||
* > DEFAULT_AVATAR_STYLE。
|
||||
*/
|
||||
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||
let style: AvatarStyle | null = null;
|
||||
if (storage) {
|
||||
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||
}
|
||||
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||
return readAvatarOverride() || style;
|
||||
}
|
||||
|
||||
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||
} catch {
|
||||
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||
} catch {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||
*/
|
||||
export function pickAvatarText(name: string | null | undefined): string {
|
||||
let s = String(name ?? "");
|
||||
const at = s.indexOf("@");
|
||||
if (at > -1) s = s.slice(0, at);
|
||||
const ch = [...s.trim()][0];
|
||||
if (ch === undefined) return "";
|
||||
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||
if (/^[0-9]$/.test(ch)) return "";
|
||||
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||
return ch;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||
|
||||
/**
|
||||
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||
*
|
||||
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||
* 都不再需要同步改任何魔法数字。
|
||||
*
|
||||
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||
*/
|
||||
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||
const height = ref(0);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
const measure = () => {
|
||||
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||
};
|
||||
|
||||
const observe = () => {
|
||||
ro?.disconnect();
|
||||
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||
measure();
|
||||
ro = new ResizeObserver(measure);
|
||||
ro.observe(el.value);
|
||||
};
|
||||
|
||||
onMounted(observe);
|
||||
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||
watch(el, observe);
|
||||
onBeforeUnmount(() => ro?.disconnect());
|
||||
|
||||
return height;
|
||||
}
|
||||
+12
-33
@@ -1,5 +1,5 @@
|
||||
import { IObj, UseTableSetting } from "@/types/Basics";
|
||||
import { resolveComponent } from "vue";
|
||||
import { actionIcon, actionText } from "@/utils/tableActions";
|
||||
export default class Utils {
|
||||
/**
|
||||
* 格式化日期
|
||||
@@ -90,7 +90,8 @@ export default class Utils {
|
||||
}
|
||||
|
||||
/**
|
||||
* iview Tooltip封装
|
||||
* iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
|
||||
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
|
||||
*/
|
||||
public static operatingHandle(
|
||||
h: any,
|
||||
@@ -99,30 +100,10 @@ export default class Utils {
|
||||
callback: Function,
|
||||
disabled: boolean = false,
|
||||
) {
|
||||
if (disabled) iconClass += " disabled";
|
||||
|
||||
return h(
|
||||
resolveComponent("Tooltip"),
|
||||
{
|
||||
content: info,
|
||||
placement: "bottom",
|
||||
transfer: true,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-btn iconfont ${iconClass}`,
|
||||
onClick: callback,
|
||||
},
|
||||
null,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
|
||||
}
|
||||
|
||||
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
|
||||
public static operatingTextHandle(
|
||||
h: any,
|
||||
info: string,
|
||||
@@ -130,15 +111,13 @@ export default class Utils {
|
||||
callback: Function,
|
||||
disabled: boolean = false,
|
||||
) {
|
||||
if (disabled) className += " disabled";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-link ${className}`,
|
||||
onClick: disabled ? null : callback,
|
||||
},
|
||||
info,
|
||||
);
|
||||
return actionText(h, {
|
||||
key: info,
|
||||
label: info,
|
||||
danger: (className || "").includes("text-error"),
|
||||
on: () => callback(),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
|
||||
*
|
||||
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
|
||||
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
|
||||
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
|
||||
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
|
||||
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
|
||||
*
|
||||
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
|
||||
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
|
||||
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
|
||||
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
|
||||
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
|
||||
*/
|
||||
import { h, resolveComponent } from "vue";
|
||||
|
||||
export interface ActionItem {
|
||||
/** 稳定标识:溢出分桶按 key 去重,必填 */
|
||||
key: string;
|
||||
label: string;
|
||||
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
|
||||
on: (row: any) => void;
|
||||
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
|
||||
icon?: string;
|
||||
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
|
||||
danger?: boolean;
|
||||
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
|
||||
when?: (row: any) => boolean;
|
||||
/** 直接置灰 */
|
||||
disabled?: boolean;
|
||||
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
|
||||
disabledWhen?: (row: any) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionCellOpts {
|
||||
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
|
||||
mode?: "icon" | "text";
|
||||
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
|
||||
collapsible?: boolean;
|
||||
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
|
||||
maxInline?: number;
|
||||
/** 列宽;缺省 icon = n*40+40 / text = 180 */
|
||||
width?: number;
|
||||
align?: "center" | "left" | "right";
|
||||
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
|
||||
fixed?: "right" | "left" | false;
|
||||
}
|
||||
|
||||
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
|
||||
|
||||
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
|
||||
export function actionIcon(h: any, item: ActionItem) {
|
||||
return h(
|
||||
resolveComponent("Tooltip"),
|
||||
{ content: item.label, placement: "bottom", transfer: true },
|
||||
{
|
||||
default: () => [
|
||||
h("div", {
|
||||
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
|
||||
onClick: item.disabled ? null : item.on,
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
|
||||
export function actionText(h: any, item: ActionItem) {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
|
||||
onClick: item.disabled ? null : item.on,
|
||||
},
|
||||
item.label,
|
||||
);
|
||||
}
|
||||
|
||||
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
|
||||
export function actionMore(h: any, items: ActionItem[]) {
|
||||
return h(
|
||||
resolveComponent("Dropdown"),
|
||||
{ class: "op-more", placement: "bottom-end", transfer: true },
|
||||
{
|
||||
default: () => [
|
||||
h("div", { class: "operating-link op-more-label" }, [
|
||||
"更多",
|
||||
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
|
||||
]),
|
||||
],
|
||||
list: () => [
|
||||
h(resolveComponent("DropdownMenu"), {}, {
|
||||
default: () => items.map((it) =>
|
||||
h(
|
||||
resolveComponent("DropdownItem"),
|
||||
{
|
||||
key: it.key,
|
||||
divided: !!(it.danger && items.length > 1),
|
||||
disabled: !!it.disabled,
|
||||
class: it.danger ? "op-item-danger" : "",
|
||||
onClick: () => it.on(),
|
||||
},
|
||||
{ default: () => it.label },
|
||||
),
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ 轴 2:归一化(纯数据) ============ */
|
||||
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
|
||||
return items
|
||||
.filter((it) => (it.when ? it.when(row) : true))
|
||||
.map((it) => ({
|
||||
...it,
|
||||
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
|
||||
on: () => it.on(row),
|
||||
}))
|
||||
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
|
||||
}
|
||||
|
||||
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
|
||||
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
|
||||
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
|
||||
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
|
||||
const inlineKeys = new Set(inline.map((it) => it.key));
|
||||
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
|
||||
}
|
||||
|
||||
/* ============ 组装层 ============ */
|
||||
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
|
||||
|
||||
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||
const mode = opts.mode ?? "text";
|
||||
const { inline, overflow } = (opts.collapsible ?? true)
|
||||
? splitActions(items, opts.maxInline ?? 2)
|
||||
: { inline: items, overflow: [] };
|
||||
|
||||
const render = RENDERERS[mode] || actionText;
|
||||
const children = inline.map((it) => render(h, it));
|
||||
if (overflow.length) children.push(actionMore(h, overflow));
|
||||
|
||||
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
|
||||
}
|
||||
|
||||
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
|
||||
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||
const mode = opts.mode ?? "text";
|
||||
return {
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
|
||||
align: opts.align ?? "center",
|
||||
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
|
||||
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
|
||||
};
|
||||
}
|
||||
@@ -372,14 +372,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.school-name {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
min-height: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
|
||||
+77
-28
@@ -38,18 +38,19 @@
|
||||
<Icon class="collapse-icon"
|
||||
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
||||
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||
<span class="cat-spacer"></span>
|
||||
<span class="inherit-cap" title="勾选后子组继承本次授权,不勾选仅本组生效">子组继承</span>
|
||||
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
|
||||
<span class="expand-all" @click.stop>
|
||||
<span class="expand-all-label">全部展开:</span>
|
||||
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
|
||||
</span>
|
||||
<span class="inherit-cap" title="勾选后子组继承本次授权,不勾选仅本组生效">子组继承</span>
|
||||
</div>
|
||||
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
||||
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
|
||||
:expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
|
||||
<!-- 「N个应用」计数:绝对定位到整个卡片的右下角(与子组继承复选框同列) -->
|
||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +90,10 @@ type AppItem = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
/** 应用级勾选态:仅当 modules 为空时有意义(对应后端 module_id=0 的整应用授权记录) */
|
||||
checked?: boolean;
|
||||
/** 应用级子组继承:仅当 modules 为空时有意义 */
|
||||
inheritFlag?: boolean;
|
||||
modules?: AppModuleVo[];
|
||||
};
|
||||
|
||||
@@ -220,19 +225,22 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
||||
|
||||
function isAppChecked(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
return modules.every((m) => isModuleAllChecked(m));
|
||||
// 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
|
||||
if (modules.length === 0) return !!app.checked;
|
||||
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
|
||||
return modules.some((m) => isModuleAnyChecked(m));
|
||||
}
|
||||
|
||||
function isAppInherit(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
if (modules.length === 0) return !!app.inheritFlag;
|
||||
return modules.every((m) => isModuleAllInherit(m));
|
||||
}
|
||||
|
||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
||||
if (!node.checked) return false;
|
||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
||||
/** 自身或任一子孙被勾选 */
|
||||
function isModuleAnyChecked(node: AppModuleVo): boolean {
|
||||
if (node.checked) return true;
|
||||
return (node.children || []).some((c) => isModuleAnyChecked(c));
|
||||
}
|
||||
|
||||
function isModuleAllInherit(node: AppModuleVo): boolean {
|
||||
@@ -247,7 +255,8 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||
id: `mod_${m.id}`,
|
||||
name: String(m.name || ""),
|
||||
open: true,
|
||||
checked: !!m.checked,
|
||||
// 父模块节点沿用「有子节点选中即选中」的展示口径,与顶层应用节点一致
|
||||
checked: isModuleAnyChecked(m),
|
||||
inheritFlag: !!m.inheritFlag,
|
||||
nodeType: "module",
|
||||
rawId: Number(m.id),
|
||||
@@ -422,6 +431,10 @@ function findLocalApp(appId: number): AppItem | null {
|
||||
function setLocalAppInherit(appId: number, inherit: boolean) {
|
||||
const app = findLocalApp(appId);
|
||||
if (!app) return;
|
||||
if ((app.modules || []).length === 0) {
|
||||
app.inheritFlag = inherit;
|
||||
return;
|
||||
}
|
||||
walkModules(app.modules || [], (n) => {
|
||||
if (n.checked) n.inheritFlag = inherit;
|
||||
});
|
||||
@@ -452,6 +465,33 @@ async function onTreeCheck(node: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 无子模块应用(如「一周安排」):授权粒度即整个应用,落 moduleId=0 的整应用记录。
|
||||
// 这类应用没有可 diff 的模块明细,必须单独走应用级提交,否则勾选只是个视觉状态、刷新即失效。
|
||||
const appModules = app.modules || [];
|
||||
if (appModules.length === 0) {
|
||||
const checked = !!node.checked;
|
||||
try {
|
||||
if (checked !== !!app.checked) {
|
||||
app.checked = checked;
|
||||
await Apis.GroupAuthz.setGroupModule({
|
||||
data: [{
|
||||
groupId,
|
||||
appId: node.appId,
|
||||
moduleId: 0,
|
||||
mode: checked ? addMode.value : deleteMode.value,
|
||||
isAll: 0,
|
||||
}],
|
||||
cacheFor: null,
|
||||
});
|
||||
}
|
||||
syncInheritDoms(treeObj);
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "操作失败");
|
||||
await reloadCurrent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 级联前快照:用于计算本次实际变化的模块明细
|
||||
const before = new Map<number, boolean>();
|
||||
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
|
||||
@@ -602,14 +642,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.group-name {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
min-height: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
@@ -663,9 +703,8 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-left: 6px;
|
||||
/* 卡片是 3 列布局,列宽有限:用超大的收缩系数让分类名先让位(省略号),
|
||||
保证右侧「子组继承 / 全部展开」整组不被挤出可视区 */
|
||||
flex: 0 999 auto;
|
||||
/* 「N个应用」计数已绝对定位脱离主行,主行只剩分类名 + 全部展开 + 子组继承 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -673,17 +712,17 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.cat-count {
|
||||
/* 计数贴在整张卡片右下角:与右侧「子组继承」复选框同列,脱离 tree 的文档流 */
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 6px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
/* 次优先收缩:空间实在不够时先截断数量文案,也不挤走开关 */
|
||||
flex: 0 10 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 弹性空隙:把右侧「子组继承 | 全部展开」整组推到卡片头右端,与分类名/数量拉开距离 */
|
||||
/* 弹性空隙:把「全部展开 / 子组继承」推到卡片头右端,与分类名拉开距离 */
|
||||
.cat-spacer {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@@ -693,6 +732,7 @@ onMounted(async () => {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
background: #f0f2f5;
|
||||
@@ -705,10 +745,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
/* 与左侧「子组继承」提示以竖线分隔:否则两个标签相邻会被读成同一个开关的说明 */
|
||||
margin-left: 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #e8eaec;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.expand-all-label {
|
||||
@@ -718,8 +755,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.cat-card-body {
|
||||
padding: 6px 8px;
|
||||
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */
|
||||
/* 底部多留一行:容纳右下角的「N个应用」计数,避免压住最后一行节点 */
|
||||
padding: 6px 8px 24px;
|
||||
/* 子组继承复选框 / 计数角标的绝对定位锚点:保证两者右对齐成一列 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -730,13 +768,22 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* 父级勾选样式(2026-10-20):
|
||||
zTree 的「半选」在两个状态位各有一套图标——true_part(父已勾但子未全勾)/
|
||||
false_part(父未勾但子有勾)。这里把 true_part 的图标换成 true_full 的蓝底勾,
|
||||
让"只要有一个子节点被选中"的父节点统一呈现为选中态,避免折叠时看着像没授权。 */
|
||||
:deep(.ztree li span.button.chk.checkbox_true_part),
|
||||
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
|
||||
background-position: -26px -5px;
|
||||
}
|
||||
|
||||
/* zTree 节点行尾的"子组继承"复选框:
|
||||
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
||||
:deep(.inherit-wrap) {
|
||||
@@ -756,7 +803,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
:deep(.ztree li a) {
|
||||
max-width: calc(100% - 90px);
|
||||
/* 子组继承复选框仅悬停时由 addHoverDom 注入(绝对定位 right:14,占位约 30px),
|
||||
常态无需预留 90px——收窄预留,消除「明明有空间却截断成 ...」 */
|
||||
max-width: calc(100% - 36px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -22,7 +22,7 @@ let onSuccessCallback: (() => void) | null = null;
|
||||
// useForm 托管提交
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppSettings.addApp({ data: dto }),
|
||||
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
{ initialForm: { name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
@@ -50,11 +50,15 @@ async function handleSubmit(formData: Record<string, any>) {
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
appCode: formData.appCode,
|
||||
appNature: formData.appNature,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
iconKey: formData.iconKey,
|
||||
iconColor: formData.iconColor,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
|
||||
+164
-24
@@ -33,6 +33,31 @@
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 应用性质 / 技术标识(学校模式隐藏——性质与技术标识均为平台主数据) -->
|
||||
<Row v-if="!schoolMode">
|
||||
<Col span="12">
|
||||
<FormItem label="应用性质:">
|
||||
<Switch v-model="natureBase" size="small" />
|
||||
<span class="nature-hint">{{ natureBase ? '基石应用' : '业务应用' }}</span>
|
||||
<Tooltip :max-width="320" content="基石应用不参与应用开通 / 授权 / 模块管理,不出现在门户应用列表与跳转注册表" placement="top" transfer>
|
||||
<Icon type="md-help" class="help-icon" />
|
||||
</Tooltip>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="技术标识:" prop="appCode">
|
||||
<div class="secret-row">
|
||||
<Input v-model="form.appCode" placeholder="如 flow-center(= 部署名,创建后尽量不改)" style="flex:1" />
|
||||
<Tooltip :max-width="320"
|
||||
content="技术标识(app_code):kebab-case,小写字母开头,仅小写字母/数字/连字符,≤32 位;= projman 部署名(容器名/网关 ctx/服务目录键),创建后尽量不改。"
|
||||
placement="top" transfer>
|
||||
<Button size="small" class="ml-8" icon="md-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第三行:appKey / appSecret(编辑模式且非学校模式才显示) -->
|
||||
<Row v-if="isEdit && !schoolMode">
|
||||
<Col span="12">
|
||||
@@ -50,8 +75,8 @@
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第四行:应用前端类型(tag 多选;学校模式隐藏) -->
|
||||
<Row v-if="!schoolMode">
|
||||
<!-- 第四行:应用前端类型(tag 多选;学校模式/基石应用隐藏——基石不进门户,无投放载体) -->
|
||||
<Row v-if="!schoolMode && !natureBase">
|
||||
<Col span="24">
|
||||
<FormItem label="应用前端类型:">
|
||||
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
|
||||
@@ -61,14 +86,14 @@
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第五行:Pc应用地址 / Pc应用图标(学校模式仅保留 Pc应用图标) -->
|
||||
<Row>
|
||||
<!-- 第五行:Pc应用地址 / 应用图标(平台模式=AppIcon 矢量选择器;学校模式保留位图上传——学校覆盖线只收 pcIcon) -->
|
||||
<Row v-if="!natureBase">
|
||||
<Col span="12" v-if="!schoolMode">
|
||||
<FormItem label="Pc应用地址:">
|
||||
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<Col span="12" v-if="schoolMode">
|
||||
<FormItem label="Pc应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
@@ -84,35 +109,73 @@
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12" v-else>
|
||||
<FormItem label="应用图标:">
|
||||
<AppIconPicker v-model="form.iconKey" v-model:color="form.iconColor" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第六行:App接口地址 / App应用图标(学校模式隐藏) -->
|
||||
<Row v-if="!schoolMode">
|
||||
<!-- 第六行:App接口地址(学校模式/基石应用隐藏;App位图图标移入下方高级折叠区) -->
|
||||
<Row v-if="!schoolMode && !natureBase">
|
||||
<Col span="12">
|
||||
<FormItem label="App接口地址:">
|
||||
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="App应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.appIcon" 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="form.appIcon = null" class="cloase-btn" name="widget-close" size="18"
|
||||
color="#fff" />
|
||||
<img :src="previewUrl(form.appIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Row>
|
||||
|
||||
<!-- 位图上传(高级 · 遗留通道):存量 pc_icon/app_icon 位图链路保留,新应用优先矢量图标键 -->
|
||||
<Row v-if="!schoolMode && !natureBase">
|
||||
<Col span="24">
|
||||
<Collapse>
|
||||
<Panel name="bitmap">
|
||||
上传位图图标(高级 · 遗留通道)
|
||||
<template #content>
|
||||
<div class="bitmap-upload-row">
|
||||
<div class="bitmap-upload-item">
|
||||
<div class="bitmap-label">Pc应用图标:</div>
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')"
|
||||
:before-upload="beforeBitmapUpload" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.pcIcon" 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="form.pcIcon = null" class="cloase-btn"
|
||||
name="widget-close" size="18" color="#fff" />
|
||||
<img :src="previewUrl(form.pcIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
<div class="bitmap-hint">矢量图标(上方)优先生效;此位图为存量回退。</div>
|
||||
</div>
|
||||
<div class="bitmap-upload-item">
|
||||
<div class="bitmap-label">App应用图标:</div>
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')"
|
||||
:before-upload="beforeBitmapUpload" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.appIcon" 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="form.appIcon = null" class="cloase-btn"
|
||||
name="widget-close" size="18" color="#fff" />
|
||||
<img :src="previewUrl(form.appIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
<div class="bitmap-hint">存量移动端位图;矢量图标优先生效。</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式隐藏) -->
|
||||
<Row v-if="!schoolMode">
|
||||
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式/基石应用隐藏) -->
|
||||
<Row v-if="!schoolMode && !natureBase">
|
||||
<Col span="12">
|
||||
<FormItem label="公众号应用地址:">
|
||||
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
|
||||
@@ -135,6 +198,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import AppIconPicker from "@/components/AppIconPicker.vue";
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
@@ -173,11 +237,16 @@ const defaultForm = (): Record<string, any> => ({
|
||||
desc: '',
|
||||
appKey: null,
|
||||
appSecret: '',
|
||||
appCode: '',
|
||||
appNature: 'BUSINESS',
|
||||
clients: [] as number[],
|
||||
pcUrl: '',
|
||||
pcIcon: '',
|
||||
appUrl: '',
|
||||
appIcon: '',
|
||||
// 矢量图标(AppIcon 资产库):iconKey 空 = 不使用矢量,端侧走 关键词→位图→首字 原回退链
|
||||
iconKey: '',
|
||||
iconColor: '',
|
||||
officialAccountUrl: '',
|
||||
dingtalkUrl: '',
|
||||
});
|
||||
@@ -211,6 +280,17 @@ function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
|
||||
}
|
||||
}
|
||||
|
||||
/* 位图上传前置校验:≤64KB(与后端 P4 校验同口径;svg 另由后端消毒) */
|
||||
const BITMAP_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function beforeBitmapUpload(file: File) {
|
||||
if (file.size > BITMAP_MAX_BYTES) {
|
||||
Message.error(`图标文件 ${(file.size / 1024).toFixed(0)}KB 超过 64KB 上限,请压缩后上传`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------- 前端类型选项 ---------- */
|
||||
// value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key):
|
||||
// 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行
|
||||
@@ -221,12 +301,26 @@ const clientOptions = [
|
||||
{ value: 32, label: '钉钉' },
|
||||
];
|
||||
|
||||
/* ---------- 应用性质 Switch(开=BASE 基石 / 关=BUSINESS 业务) ---------- */
|
||||
const natureBase = computed({
|
||||
get: () => form.appNature === 'BASE',
|
||||
set: (val: boolean) => { form.appNature = val ? 'BASE' : 'BUSINESS'; },
|
||||
});
|
||||
|
||||
/* ---------- 校验规则(学校模式不校验 categoryId——类型字段不展示且不可改) ---------- */
|
||||
const rules = computed(() => ({
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
...(props.schoolMode ? {} : {
|
||||
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
|
||||
}),
|
||||
// 技术标识新增与编辑均必填(2026-09-28 拍板,后端 Create/Update 组同口径);学校模式不展示该字段
|
||||
...(props.schoolMode ? {} : {
|
||||
appCode: [
|
||||
{ required: true, message: '请输入技术标识', trigger: 'blur' },
|
||||
// 2026-09-28 拍板命名规范:app_code 一律 kebab-case 且 = 部署名(PlatformService 枚举头注释同源)
|
||||
{ pattern: /^[a-z][a-z0-9-]{0,31}$/, message: '小写字母开头,仅小写字母/数字/连字符,≤32 位', trigger: 'blur' },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
@@ -348,4 +442,50 @@ function onVisibleChange(val: boolean) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 位图上传(高级折叠区):并排两枚上传位 */
|
||||
.bitmap-upload-row {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.bitmap-upload-item {
|
||||
.bitmap-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bitmap-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
/* 应用性质说明文字 */
|
||||
.nature-hint {
|
||||
margin-left: 8px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表单帮助图标(Tooltip 触发器):静默态用中性灰阶令牌,交互态才升主色(含淡底),
|
||||
一律不写死色号——切档位(blue/ocean/ink/violet/teal)自动跟随。
|
||||
括号内兜底值 = 默认档 blue,仅防通道 CSS 不可达时图标失色(接入后由通道令牌接管)。 */
|
||||
.help-icon {
|
||||
margin-left: 6px;
|
||||
padding: 1px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-3, #98a1b2);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand, #577afb);
|
||||
background-color: var(--brand-soft, #eef2ff);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+54
-58
@@ -31,7 +31,7 @@
|
||||
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" />
|
||||
</div>
|
||||
|
||||
<!-- 容器组件:新增 / 编辑 -->
|
||||
@@ -51,7 +51,6 @@
|
||||
import { ref, computed } from "vue";
|
||||
defineOptions({ name: "AppList" });
|
||||
import { useRouter } from "vue-router";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import AddAppContainer from "./AddAppContainer.vue";
|
||||
import EditAppContainer from "./EditAppContainer.vue";
|
||||
@@ -60,9 +59,9 @@ import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import { OperatorEnum } from '@/types';
|
||||
import AppSort from './AppSort.vue';
|
||||
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
const router = useRouter();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
|
||||
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
|
||||
@@ -146,10 +145,6 @@ function onSortSaved() {
|
||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
|
||||
const tableHeight = ref(
|
||||
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
|
||||
);
|
||||
|
||||
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
||||
const columns = [
|
||||
{ type: "index", title: "序号", width: 70 },
|
||||
@@ -169,21 +164,46 @@ const columns = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
align: 'center',
|
||||
width: 280,
|
||||
render: (h: any, { row }: any) => {
|
||||
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏
|
||||
const actions = [$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row))];
|
||||
if (!isSchoolMode.value) {
|
||||
actions.push($utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)));
|
||||
actions.push($utils.operatingTextHandle(h, '推送设置', '', () => openPushSetting(row)));
|
||||
actions.push($utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)));
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
title: "性质",
|
||||
key: "appNature",
|
||||
width: 80,
|
||||
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
|
||||
},
|
||||
actionColumn(
|
||||
[
|
||||
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
|
||||
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
|
||||
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
|
||||
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||
{
|
||||
key: "module",
|
||||
label: "模块管理",
|
||||
icon: "icon-apps",
|
||||
on: (r: any) => goModuleManage(r),
|
||||
when: () => !isSchoolMode.value,
|
||||
disabledWhen: (r: any) => r.appNature === "BASE",
|
||||
},
|
||||
{
|
||||
key: "push",
|
||||
label: "推送设置",
|
||||
icon: "icon-fasong",
|
||||
on: (r: any) => openPushSetting(r),
|
||||
when: () => !isSchoolMode.value,
|
||||
disabledWhen: (r: any) => r.appNature === "BASE",
|
||||
},
|
||||
{
|
||||
key: "del",
|
||||
label: "删除",
|
||||
icon: "icon-ashbin",
|
||||
on: (r: any) => handleDelete(r),
|
||||
when: () => !isSchoolMode.value,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
// UserBase 恒文字链(工程风格显式声明,不随动作数漂移);
|
||||
// icon 字段仅留作未来切 mode:"icon" 的备选。4 动作 → 2 直出 + 推送设置/删除进「更多」
|
||||
{ mode: "text", width: 180 },
|
||||
),
|
||||
];
|
||||
|
||||
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
||||
@@ -231,18 +251,24 @@ function openSortModal() {
|
||||
sortModalVisible.value = true;
|
||||
}
|
||||
|
||||
/* ---------------- 删除应用 ---------------- */
|
||||
/* ---------------- 删除应用(删除模:输名确认 + 红色确认键,对齐 projman;基石应用同样可删) ---------------- */
|
||||
async function handleDelete(row: any) {
|
||||
const name = row.name || String(row.id);
|
||||
await MyConfirm({
|
||||
msg: `确定删除应用「${row.name || row.id}」吗?`,
|
||||
info: "删除后不可恢复!",
|
||||
title: "删除应用",
|
||||
msg: `确定删除应用「${name}」吗?`,
|
||||
info: "删除后不可恢复,关联就绪资源需手动清理!"
|
||||
+ (row.appNature === "BASE" ? "(基石应用:请先确认无平台组件仍依赖其 appKey 签名身份)" : ""),
|
||||
confirmName: name,
|
||||
danger: true,
|
||||
confirmText: "删除",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
|
||||
Message.success('删除成功!');
|
||||
tableRef.value?.reloadData(OperatorEnum.del);
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || '删除失败');
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -273,7 +299,6 @@ const goModuleManage = (row: any) => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.school-select {
|
||||
@@ -295,7 +320,7 @@ const goModuleManage = (row: any) => {
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,37 +341,8 @@ const goModuleManage = (row: any) => {
|
||||
}
|
||||
|
||||
li.active {
|
||||
color: #577afb;
|
||||
border-color: #577afb;
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
|
||||
&:hover {
|
||||
color: #c51d04;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
margin: 0 8px;
|
||||
color: #dcdee2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,7 @@ const { form, loading: submitting, send: sendForm, onSuccess, onError } = useFor
|
||||
data: dto,
|
||||
params: props.schoolId != null ? { schoolId: props.schoolId } : undefined,
|
||||
}),
|
||||
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
@@ -67,11 +67,16 @@ function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
if (isSchoolMode) {
|
||||
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表,其余字段一律不提交
|
||||
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个);
|
||||
// categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400;
|
||||
// iconKey/iconColor 不送键(后端 null=不改——学校线不动平台矢量配置)
|
||||
form.value = {
|
||||
id: currentId.value,
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
appCode: formData.appCode,
|
||||
appNature: formData.appNature,
|
||||
pcIcon: formData.pcIcon,
|
||||
};
|
||||
} else {
|
||||
@@ -80,11 +85,15 @@ async function handleSubmit(formData: Record<string, any>) {
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
appCode: formData.appCode,
|
||||
appNature: formData.appNature,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
iconKey: formData.iconKey,
|
||||
iconColor: formData.iconColor,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
<Input placeholder="请输入模块名称" v-model="form.name" />
|
||||
</FormItem>
|
||||
<FormItem label="模块路径:" prop="path">
|
||||
<Input placeholder="请输入模块路径" v-model="form.path" />
|
||||
<Input :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入模块路径'" v-model="form.path" />
|
||||
</FormItem>
|
||||
<FormItem label="模块图标:" prop="icon">
|
||||
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
|
||||
v-model="form.icon" clearable />
|
||||
</FormItem>
|
||||
<FormItem label="自定义ID:" prop="customId">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" />
|
||||
<InputNumber :style="{ width: '100%' }" :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入自定义ID'" v-model="form.customId" />
|
||||
</FormItem>
|
||||
<FormItem label="排序号:" prop="sortNo">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
|
||||
@@ -54,9 +54,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { debounce } from "lodash";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -116,22 +114,18 @@ const columns = [
|
||||
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
|
||||
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
|
||||
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 220,
|
||||
align: "center",
|
||||
render: (h: any, { row }: any) => {
|
||||
const actions: any[] = [
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
||||
];
|
||||
if (row.level < 5) {
|
||||
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
|
||||
}
|
||||
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
|
||||
// UserBase 恒文字链;3 动作 ≤ maxInline+1 → 全直出(与改前形态一致),列宽 220 → 180
|
||||
actionColumn([
|
||||
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||
{
|
||||
key: "add",
|
||||
label: "添加子模块",
|
||||
icon: "icon-tianjia",
|
||||
on: (r: any) => handleAddChild(r),
|
||||
when: (r: any) => r.level < 5,
|
||||
},
|
||||
},
|
||||
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => handleDelete(r), danger: true },
|
||||
], { mode: "text", width: 180 }),
|
||||
];
|
||||
|
||||
/* ---------------- 弹窗状态 ---------------- */
|
||||
@@ -159,13 +153,16 @@ const form = ref<{
|
||||
sortNo: null,
|
||||
});
|
||||
|
||||
const ruleValidate = ref({
|
||||
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
||||
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
||||
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
||||
});
|
||||
|
||||
const openingIsChild = ref(false);
|
||||
|
||||
/* 子模块双定位:树节点(带路径/编码)或纯按钮权限(两者皆可空)→ 仅根模块必填 */
|
||||
const ruleValidate = computed(() => ({
|
||||
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
||||
...(openingIsChild.value ? {} : {
|
||||
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
||||
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
||||
}),
|
||||
}));
|
||||
const modalTitle = computed(() => {
|
||||
if (currentId.value != null) return '编辑模块';
|
||||
return openingIsChild.value ? '新增子模块' : '新增模块';
|
||||
@@ -264,7 +261,7 @@ async function onRawSave() {
|
||||
data: {
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
path: form.value.path?.trim() || undefined,
|
||||
icon: form.value.icon?.trim() || undefined,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
@@ -278,8 +275,8 @@ async function onRawSave() {
|
||||
id: currentId.value,
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标能生效
|
||||
path: form.value.path?.trim() || undefined,
|
||||
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
|
||||
icon: form.value.icon?.trim() || undefined,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<!-- 自定义部门卡片(已被 CustomDeptPanel.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||
<template>
|
||||
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
|
||||
<div class="row-1">
|
||||
<span class="status-dot" :class="{ 'is-off': !dept.enabled }"></span>
|
||||
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
|
||||
<span class="sw" :class="{ 'is-off': !dept.enabled }" :title="dept.enabled ? '点击停用' : '点击启用'"
|
||||
@click="emit('toggle', dept)"></span>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<span class="sub">{{ memberText }}</span>
|
||||
<span class="ops">
|
||||
<i class="op" :class="{ 'is-disabled': !canMoveUp }" title="上移" @click="emit('moveUp')">↑</i>
|
||||
<i class="op" :class="{ 'is-disabled': !canMoveDown }" title="下移" @click="emit('moveDown')">↓</i>
|
||||
<a class="link" @click="emit('members', dept)">成员</a>
|
||||
<a class="link" @click="emit('edit', dept)">编辑</a>
|
||||
<a class="link danger" @click="emit('remove', dept)">删除</a>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import type { CustomDepartmentVo } from "@/api/globals";
|
||||
|
||||
/**
|
||||
* CUSTOM 部门卡片:与 SYSTEM 的 DeptCard 同款外观,但字段来自 ub_custom_department。
|
||||
* 停用在管理端照常可见(灰态),软删的部门由后端列表过滤、不会走到这里。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
dept: CustomDepartmentVo;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", dept: CustomDepartmentVo): void;
|
||||
(e: "edit", dept: CustomDepartmentVo): void;
|
||||
(e: "remove", dept: CustomDepartmentVo): void;
|
||||
(e: "members", dept: CustomDepartmentVo): void;
|
||||
(e: "moveUp"): void;
|
||||
(e: "moveDown"): void;
|
||||
}>();
|
||||
|
||||
const memberText = computed(() => {
|
||||
const count = Number(props.dept?.memberCount ?? 0);
|
||||
if (!props.dept?.enabled) return count ? `已停用 · ${count} 人` : "已停用 · 无成员";
|
||||
return count ? `${count} 人` : "无成员(可先建部门再配人)";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cdept-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 13px 14px;
|
||||
background: var(--panel, #fff);
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-radius: var(--r, 10px);
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.cdept-card:hover {
|
||||
border-color: #dbe2fb;
|
||||
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
|
||||
}
|
||||
|
||||
.cdept-card.is-off {
|
||||
background: #fbfcfd;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex: 0 0 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green, #20a162);
|
||||
}
|
||||
|
||||
.status-dot.is-off {
|
||||
background: #cbd2df;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--text, #1e2635);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cdept-card.is-off .card-name {
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
margin-top: auto;
|
||||
padding-top: 9px;
|
||||
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 .sub {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link.danger {
|
||||
color: #e54545;
|
||||
}
|
||||
|
||||
.op {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #5a6478);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.op:hover {
|
||||
background: #e8ecfa;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
|
||||
.op.is-disabled {
|
||||
color: #d3d9e6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 启停开关(与 SYSTEM 卡片同一套视觉) */
|
||||
.sw {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
|
||||
transition: left .15s;
|
||||
}
|
||||
|
||||
.sw.is-off {
|
||||
background: #d3d9e6;
|
||||
}
|
||||
|
||||
.sw.is-off::after {
|
||||
left: 2px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<Modal v-model="innerVisible" :title="title" width="760" :mask-closable="false"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<div class="member-body">
|
||||
<!-- 左:全部人员(本校教职工,服务端已排除学生组与在册成员) -->
|
||||
<section class="pane">
|
||||
<div class="pane-head">
|
||||
<Checkbox :model-value="leftAllChecked" :indeterminate="leftIndeterminate"
|
||||
@on-change="(v: any) => toggleAll(leftRows, leftChecked, !!v)" />
|
||||
<span class="pane-title">全部人员</span>
|
||||
<span class="pane-sub">{{ leftCheckedCount }}/{{ leftRows.length }}</span>
|
||||
</div>
|
||||
<div class="pane-filter">
|
||||
<Input v-model="kwLeft" size="small" placeholder="请输入搜索内容" clearable search
|
||||
@on-search="loadCandidates" @on-change="onKwLeftChange" />
|
||||
</div>
|
||||
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
|
||||
<div v-else-if="!leftRows.length" class="pane-tip">
|
||||
{{ kwLeft ? '没有匹配的人员' : '没有可添加的教职工' }}
|
||||
</div>
|
||||
<ul v-else class="pane-list">
|
||||
<li v-for="u in leftRows" :key="u.userId" class="row" @dblclick="moveRightOne(u)">
|
||||
<Checkbox :model-value="isChecked(leftChecked, u)" @on-change="(v: any) => toggleOne(leftChecked, u, !!v)">
|
||||
<span class="row-name">{{ u.name || u.account }}</span>
|
||||
<span class="row-sub">{{ u.account }}</span>
|
||||
</Checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
|
||||
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 中:加入 / 移出 -->
|
||||
<div class="mid">
|
||||
<Button size="small" class="mid-btn" :disabled="!rightCheckedCount" title="移出成员"
|
||||
@click="moveLeft()"><</Button>
|
||||
<Button size="small" type="primary" class="mid-btn" :disabled="!leftCheckedCount"
|
||||
title="加入成员" @click="moveRight()">></Button>
|
||||
</div>
|
||||
|
||||
<!-- 右:已有成员 -->
|
||||
<section class="pane">
|
||||
<div class="pane-head">
|
||||
<Checkbox :model-value="rightAllChecked" :indeterminate="rightIndeterminate"
|
||||
@on-change="(v: any) => toggleAll(rightRows, rightChecked, !!v)" />
|
||||
<span class="pane-title">已有成员</span>
|
||||
<span class="pane-sub">{{ rightRows.length }} 人</span>
|
||||
</div>
|
||||
<div class="pane-filter">
|
||||
<Input v-model="kwRight" size="small" placeholder="请输入搜索内容" clearable />
|
||||
</div>
|
||||
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
|
||||
<div v-else-if="!rightRows.length" class="pane-tip">
|
||||
{{ kwRight ? '没有匹配的成员' : '该部门还没有成员' }}
|
||||
</div>
|
||||
<ul v-else class="pane-list">
|
||||
<li v-for="u in rightRows" :key="u.userId" class="row" @dblclick="moveLeftOne(u)">
|
||||
<Checkbox :model-value="isChecked(rightChecked, u)" @on-change="(v: any) => toggleOne(rightChecked, u, !!v)">
|
||||
<span class="row-name">{{ u.name || u.account }}</span>
|
||||
<span class="row-sub">{{ u.account }}</span>
|
||||
</Checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<span v-if="pendingText" class="pending-tip">{{ pendingText }}</span>
|
||||
<Button @click="innerVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" :disabled="!hasPending" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
|
||||
|
||||
/**
|
||||
* 自定义部门成员管理弹窗(穿梭框式)。
|
||||
* 左「全部人员」=本校教职工候选(服务端按「排除学生组 + 未在本部门」给),右「已有成员」=部门在册成员。
|
||||
* 中间箭头只改本地待提交状态,点「保存」才一次性提交:先 Add 再 Remove,取消则整体丢弃。
|
||||
* 候选走 {@code /api/customdept/CandidateUsers/{id}},不能用组维度的 GroupUsers(会带进学生)。
|
||||
*/
|
||||
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
|
||||
|
||||
/** 后端候选上限 200,达到就提示用关键字筛选 */
|
||||
const CANDIDATE_PAGE_HINT = 200;
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
|
||||
const deptId = computed(() => props.dept?.id ?? null);
|
||||
|
||||
/** 服务端数据 */
|
||||
const members = ref<CustomDepartmentMemberVo[]>([]);
|
||||
const candidates = ref<CustomDepartmentMemberVo[]>([]);
|
||||
const loadingMembers = ref(false);
|
||||
const loadingCandidates = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
/** 待提交:新增(左→右)与移出(右→左)。点保存才落库,取消整体丢弃 */
|
||||
const pendingAdd = ref<CustomDepartmentMemberVo[]>([]);
|
||||
const pendingRemove = ref<Set<number>>(new Set());
|
||||
|
||||
const kwLeft = ref("");
|
||||
const kwRight = ref("");
|
||||
const leftChecked = ref<Set<number>>(new Set());
|
||||
const rightChecked = ref<Set<number>>(new Set());
|
||||
|
||||
function matchKw(u: CustomDepartmentMemberVo, k: string) {
|
||||
return (
|
||||
String(u.name || "").toLowerCase().includes(k) ||
|
||||
String(u.account || "").toLowerCase().includes(k)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左列表:候选池去掉待加入的,再补上「待移出的成员」——
|
||||
* 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。
|
||||
*/
|
||||
const leftRows = computed<CustomDepartmentMemberVo[]>(() => {
|
||||
const k = kwLeft.value.trim().toLowerCase();
|
||||
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
|
||||
const back = members.value.filter((m) => pendingRemove.value.has(m.userId as number) && hit(m));
|
||||
const pool = candidates.value.filter(
|
||||
(c) => !pendingRemove.value.has(c.userId as number) && !isPendingAdd(c.userId as number) && hit(c)
|
||||
);
|
||||
return [...back, ...pool];
|
||||
});
|
||||
|
||||
/** 右列表:在册成员去掉待移出的,加上待加入的 */
|
||||
const rightRows = computed<CustomDepartmentMemberVo[]>(() => {
|
||||
const k = kwRight.value.trim().toLowerCase();
|
||||
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
|
||||
const held = members.value.filter((m) => !pendingRemove.value.has(m.userId as number));
|
||||
return [...held, ...pendingAdd.value].filter(hit);
|
||||
});
|
||||
|
||||
function isPendingAdd(userId: number) {
|
||||
return pendingAdd.value.some((u) => u.userId === userId);
|
||||
}
|
||||
|
||||
const hasPending = computed(() => pendingAdd.value.length > 0 || pendingRemove.value.size > 0);
|
||||
const pendingText = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (pendingAdd.value.length) parts.push(`新增 ${pendingAdd.value.length} 人`);
|
||||
if (pendingRemove.value.size) parts.push(`移出 ${pendingRemove.value.size} 人`);
|
||||
return parts.join(" · ");
|
||||
});
|
||||
|
||||
/* ================= 勾选 ================= */
|
||||
|
||||
function isChecked(box: Set<number>, u: CustomDepartmentMemberVo) {
|
||||
return box.has(u.userId as number);
|
||||
}
|
||||
|
||||
function toggleOne(box: Set<number>, u: CustomDepartmentMemberVo, checked: boolean) {
|
||||
const userId = u.userId as number;
|
||||
if (checked) box.add(userId);
|
||||
else box.delete(userId);
|
||||
// Set 自身不是响应式依赖,换一个实例触发重算
|
||||
if (box === leftChecked.value) leftChecked.value = new Set(box);
|
||||
else rightChecked.value = new Set(box);
|
||||
}
|
||||
|
||||
function moveRightOne(u: CustomDepartmentMemberVo) {
|
||||
if (u.userId) moveRight([u.userId]);
|
||||
}
|
||||
|
||||
function moveLeftOne(u: CustomDepartmentMemberVo) {
|
||||
if (u.userId) moveLeft([u.userId]);
|
||||
}
|
||||
|
||||
function toggleAll(rows: CustomDepartmentMemberVo[], box: Set<number>, checked: boolean) {
|
||||
const ids = rows.map((u) => u.userId as number);
|
||||
const next = new Set(box);
|
||||
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
|
||||
if (box === leftChecked.value) leftChecked.value = next;
|
||||
else rightChecked.value = next;
|
||||
}
|
||||
|
||||
/* 全选 / 半选一律按「当前可见行」判断:关键字筛选后仍被勾着的行不算数 */
|
||||
const leftCheckedCount = computed(
|
||||
() => leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).length
|
||||
);
|
||||
const rightCheckedCount = computed(
|
||||
() => rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).length
|
||||
);
|
||||
const leftAllChecked = computed(() => leftRows.value.length > 0 && leftCheckedCount.value >= leftRows.value.length);
|
||||
const leftIndeterminate = computed(() => leftCheckedCount.value > 0 && !leftAllChecked.value);
|
||||
const rightAllChecked = computed(() => rightRows.value.length > 0 && rightCheckedCount.value >= rightRows.value.length);
|
||||
const rightIndeterminate = computed(() => rightCheckedCount.value > 0 && !rightAllChecked.value);
|
||||
|
||||
/* ================= 左右移动 ================= */
|
||||
|
||||
/** 加入:左侧勾选(或双击某行)→ 挂到待新增 */
|
||||
function moveRight(userIds?: number[]) {
|
||||
const ids = userIds ?? leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).map((u) => u.userId as number);
|
||||
if (!ids.length) return;
|
||||
const picked = leftRows.value.filter((u) => ids.includes(u.userId as number));
|
||||
pendingAdd.value = [...pendingAdd.value, ...picked];
|
||||
// 移出后再加入 = 撤销移出,不用重复 Add
|
||||
const nextRemove = new Set(pendingRemove.value);
|
||||
picked.forEach((u) => nextRemove.delete(u.userId as number));
|
||||
pendingRemove.value = nextRemove;
|
||||
leftChecked.value = new Set();
|
||||
}
|
||||
|
||||
/** 移出:右侧勾选(或双击某行)→ 撤掉待新增,或记账到待移出 */
|
||||
function moveLeft(userIds?: number[]) {
|
||||
const ids = userIds ?? rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).map((u) => u.userId as number);
|
||||
if (!ids.length) return;
|
||||
const stillPending = new Set<number>();
|
||||
pendingAdd.value = pendingAdd.value.filter((u) => {
|
||||
if (ids.includes(u.userId as number)) {
|
||||
stillPending.add(u.userId as number);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const nextRemove = new Set(pendingRemove.value);
|
||||
ids.forEach((id) => {
|
||||
if (!stillPending.has(id)) nextRemove.add(id);
|
||||
});
|
||||
pendingRemove.value = nextRemove;
|
||||
rightChecked.value = new Set();
|
||||
}
|
||||
|
||||
/* ================= 数据加载 ================= */
|
||||
|
||||
async function loadMembers() {
|
||||
if (!deptId.value) return;
|
||||
loadingMembers.value = true;
|
||||
try {
|
||||
members.value = ((await Apis.CustomDept.customDeptMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentMemberVo[]) || [];
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载部门成员失败");
|
||||
members.value = [];
|
||||
} finally {
|
||||
loadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
if (!deptId.value) return;
|
||||
loadingCandidates.value = true;
|
||||
try {
|
||||
candidates.value = ((await Apis.CustomDept.customDeptCandidateUsers({
|
||||
pathParams: { id: deptId.value },
|
||||
params: { keyword: kwLeft.value.trim() || undefined },
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentMemberVo[]) || [];
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载候选教职工失败");
|
||||
candidates.value = [];
|
||||
} finally {
|
||||
loadingCandidates.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKwLeftChange() {
|
||||
// clearable 清空时也当一次搜索
|
||||
if (!kwLeft.value) loadCandidates();
|
||||
}
|
||||
|
||||
/* ================= 保存 ================= */
|
||||
|
||||
async function save() {
|
||||
if (!deptId.value || !hasPending.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (pendingAdd.value.length) {
|
||||
await Apis.CustomDept.customDeptAddMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
data: {
|
||||
schoolId: props.dept?.schoolId as number,
|
||||
userIds: pendingAdd.value.map((u) => u.userId as number),
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
}
|
||||
if (pendingRemove.value.size) {
|
||||
await Apis.CustomDept.customDeptRemoveMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
data: { schoolId: props.dept?.schoolId as number, userIds: [...pendingRemove.value] },
|
||||
cacheFor: null,
|
||||
});
|
||||
}
|
||||
Message.success("已保存");
|
||||
emit("success");
|
||||
innerVisible.value = false;
|
||||
} catch (e: any) {
|
||||
// 失败提示由全局 alova 拦截器弹出,弹窗保持打开让用户重试
|
||||
console.warn(e?.message || "保存成员失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 生命周期 ================= */
|
||||
|
||||
function reset() {
|
||||
members.value = [];
|
||||
candidates.value = [];
|
||||
pendingAdd.value = [];
|
||||
pendingRemove.value = new Set();
|
||||
leftChecked.value = new Set();
|
||||
rightChecked.value = new Set();
|
||||
kwLeft.value = "";
|
||||
kwRight.value = "";
|
||||
}
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
if (!v) return;
|
||||
reset();
|
||||
loadMembers();
|
||||
loadCandidates();
|
||||
}
|
||||
|
||||
// 外部直接改 visible 时同步一次
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) onVisibleChange(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.member-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
height: 430px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line, #e2e6ef);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid #f1f4f9;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e2635);
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pane-filter {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
|
||||
.pane-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0 6px 6px;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pane-tip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.pane-foot {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
border-top: 1px solid #f6f8fb;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
:deep(.ivu-checkbox-wrapper) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-checkbox) {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.row-name {
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-sub {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #9aa1ad;
|
||||
}
|
||||
|
||||
/* 中间箭头列 */
|
||||
.mid {
|
||||
flex: 0 0 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mid-btn {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pending-tip {
|
||||
margin-right: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!-- 系统部门卡片(已被 index.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||
<template>
|
||||
<article class="dept-card" :class="{ 'is-off': disabled, 'has-kids': showKids }">
|
||||
<div class="row-1">
|
||||
<span class="status-dot" :class="{ 'is-off': disabled }"></span>
|
||||
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
|
||||
<span class="sw" :class="{ 'is-off': disabled }" title="启用 / 停用" @click.stop="emit('toggle', dept)"></span>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<span class="sub">{{ subText }}</span>
|
||||
</div>
|
||||
<div v-if="showKids" class="kid-list">
|
||||
<DeptCard v-for="kid in kids" :key="kid.id" :dept="kid" :level="level + 1"
|
||||
:include-children="includeChildren" @toggle="onKidToggle" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
/** 部门口径:教师组下挂的都是部门,只有学生组(type=8)不算部门 */
|
||||
const STUDENT_GROUP_TYPE = 8;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
dept: any;
|
||||
/** 层级深度:0 = 一级部门,>0 = 下级部门 */
|
||||
level?: number;
|
||||
/** 是否包含下级部门(对应学校上的「包含子组」开关) */
|
||||
includeChildren?: boolean;
|
||||
}>(), {
|
||||
level: 0,
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ (e: "toggle", dept: any): void }>();
|
||||
|
||||
const onKidToggle = (dept: any) => emit("toggle", dept);
|
||||
|
||||
const kids = computed<any[]>(() => props.dept?.children || []);
|
||||
const showKids = computed(() => props.includeChildren !== false && kids.value.length > 0);
|
||||
const disabled = computed(() => props.dept?.status === 1);
|
||||
|
||||
const subText = computed(() => {
|
||||
if (props.dept?.type === STUDENT_GROUP_TYPE) return "学生组 · 非部门";
|
||||
if (props.level > 0) return "下级部门";
|
||||
return kids.value.length ? `${kids.value.length} 个下级部门` : "无下级部门";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 13px 14px;
|
||||
background: var(--panel, #fff);
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-radius: var(--r, 10px);
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.dept-card:hover {
|
||||
border-color: #dbe2fb;
|
||||
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
|
||||
}
|
||||
|
||||
/* 有下级部门:跨整行成组,避免层级被压平 */
|
||||
.dept-card.has-kids {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dept-card.is-off {
|
||||
background: #fbfcfd;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex: 0 0 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green, #20a162);
|
||||
}
|
||||
|
||||
.status-dot.is-off {
|
||||
background: #cbd2df;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--text, #1e2635);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dept-card.is-off .card-name {
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
margin-top: auto;
|
||||
padding-top: 9px;
|
||||
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 .sub {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 启停开关 */
|
||||
.sw {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
|
||||
transition: left .15s;
|
||||
}
|
||||
|
||||
.sw.is-off {
|
||||
background: #d3d9e6;
|
||||
}
|
||||
|
||||
.sw.is-off::after {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
/* 下级部门区 */
|
||||
.kid-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 11px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||
}
|
||||
|
||||
.kid-list :deep(.dept-card) {
|
||||
flex: 1 1 168px;
|
||||
min-width: 150px;
|
||||
background: #fafbfe;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.kid-list :deep(.dept-card:hover) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.kid-list :deep(.dept-card .card-name) {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 下级部门里若还有下级,占满一行表达更深层级 */
|
||||
.kid-list :deep(.dept-card.has-kids) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,771 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper dept-manage-page">
|
||||
<div v-if="!roleReady" class="dept-loading">加载中…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 左侧:zTree 组树(超管 / 局端;只到学校层) -->
|
||||
<div v-if="showTree" class="tree-container dept-manage-tree">
|
||||
<GroupTreeSearchPanel class="tree-inner" ref="refTreePanel" v-model:curTreeNode="selectedNode"
|
||||
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
|
||||
<div class="main-container dept-manage-main">
|
||||
<div class="panel-title">
|
||||
<span class="panel-caption">部门管理</span>
|
||||
<Tag v-if="currentSchoolName" color="primary" class="ml-8">{{ currentSchoolName }}</Tag>
|
||||
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
|
||||
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
|
||||
</Select>
|
||||
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
|
||||
<div class="title-tail">
|
||||
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
|
||||
@click="toggleInclude">
|
||||
<span class="inc-label">包含子组</span>
|
||||
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
|
||||
</span>
|
||||
<div class="source-tabs">
|
||||
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
|
||||
@click="source = 'SYSTEM'">系统部门</span>
|
||||
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
|
||||
@click="source = 'CUSTOM'">自定义部门</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="source === 'SYSTEM'">
|
||||
<Alert show-icon type="success" class="usage-note">
|
||||
<b>设置说明:</b>
|
||||
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
|
||||
关闭「包含子组」只展示一级部门,打开后连同下级部门一并列出。
|
||||
</Alert>
|
||||
|
||||
<Alert v-if="!currentSchoolName" show-icon type="info" class="state-tip">
|
||||
{{ showTree ? '请在左侧选择学校节点' : '当前账号没有可管理的学校' }}
|
||||
</Alert>
|
||||
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
|
||||
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
|
||||
</Alert>
|
||||
|
||||
<template v-else>
|
||||
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
|
||||
<Table class="dept-itable" border :columns="sysColumns" :data="flatRows"
|
||||
:row-class-name="rowClassName" :loading="false" no-data-text="暂无部门">
|
||||
<template #name="{ row }">
|
||||
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
|
||||
<i v-if="row._kids.length" class="tw" @click.stop="toggleRow(row.id)">{{ row.open ? '▾' : '▸' }}</i>
|
||||
<i v-else class="tw blank"></i>
|
||||
<span class="nm" :class="{ root: row.level === 0 }" :title="row.name">{{ row.name }}</span>
|
||||
<span v-if="row.kidCount" class="cnt">{{ row.kidCount }} 个下级</span>
|
||||
<span v-if="row.isStudent" class="cnt grey">学生组</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #members="{ row }">
|
||||
<Tooltip v-if="row.memberCount" placement="top" max-width="360" :content="memberTip(row)">
|
||||
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
|
||||
@click="onMemberClick(row)">
|
||||
<span class="names">{{ memberHead(row) }}</span>
|
||||
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-else class="dim">无成员</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<i class="sw-mini" :class="{ 'is-on': row.status !== 1 }"
|
||||
:title="row.status === 1 ? '点击启用' : '点击停用'"
|
||||
@click="onToggle(row.raw)"></i>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<CustomDeptPanel v-else :school-id="currentSchoolId" />
|
||||
|
||||
<!-- 系统部门人员:预览之外看全部,走 /api/sysuser/List?groupId= -->
|
||||
<Modal v-model="memModal.visible" :title="`部门人员 · ${memModal.deptName}`" width="560"
|
||||
:mask-closable="true" footer-hide>
|
||||
<div class="mem-modal-body">
|
||||
<div v-if="memModal.loading" class="mem-empty">加载中…</div>
|
||||
<div v-else-if="!memModal.rows.length" class="mem-empty">该部门暂无直属成员</div>
|
||||
<ul v-else class="mem-list">
|
||||
<li v-for="u in memModal.rows" :key="u.id">
|
||||
<span class="mname">{{ u.realName || u.account }}</span>
|
||||
<span class="macc">{{ u.account }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="memModal.total > memModal.pageSize" class="mem-pager">
|
||||
<Page :current="memModal.page" :total="memModal.total" :page-size="memModal.pageSize"
|
||||
size="small" show-total @on-change="onMemPageChange" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { cloneDeep } from "lodash";
|
||||
import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CustomDeptPanel from "./CustomDeptPanel.vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { UserRoleEnum } from "@/types";
|
||||
|
||||
/**
|
||||
* 部门管理(2026-10-23 表格版)。
|
||||
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门(只有教师组下才可能有多级)。
|
||||
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,右侧是该学校的部门表格;
|
||||
* 学校管理员 → 无树,直接列出本校部门(多校时顶部下拉切换)。
|
||||
* 表格:层级用缩进 + 折叠表达;不提供新增 / 排序 / 行内操作,只保留启停与「包含子组」。
|
||||
* 人员:getTree 已带 memberCount + memberNames(前 50),超出走 /api/sysuser/List 看全部。
|
||||
*/
|
||||
const TEACHER_GROUP_TYPE = 7;
|
||||
const STUDENT_GROUP_TYPE = 8;
|
||||
const SCHOOL_GROUP_TYPE = 2;
|
||||
|
||||
const store = useDefaultStore();
|
||||
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
|
||||
/** 超管 / 局端才需要左侧树 */
|
||||
const showTree = computed(() => !isSchoolMode.value);
|
||||
/** 角色未落定前不渲染,避免先按默认角色取数再纠正 */
|
||||
const roleReady = computed(() => store.roleLoaded);
|
||||
|
||||
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
|
||||
const selectedNode = ref<any>({});
|
||||
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
|
||||
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
|
||||
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
|
||||
const fullTree = ref<any[]>([]);
|
||||
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
|
||||
const currentSchoolId = ref<number | undefined>(undefined);
|
||||
const schoolIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
|
||||
const multiSchool = computed(() => isSchoolMode.value && schoolIds.value.length > 1);
|
||||
|
||||
const schoolOptions = computed(() => schoolIds.value.map((id) => ({
|
||||
id,
|
||||
name: findNodeById(fullTree.value, id)?.name ?? String(id),
|
||||
})));
|
||||
|
||||
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
|
||||
const currentSchoolName = computed(() => currentSchool.value?.name || "");
|
||||
|
||||
/** 折叠状态:默认全展开,只记「被收起的 id」 */
|
||||
const closedIds = ref<Set<number>>(new Set());
|
||||
function toggleRow(id: number) {
|
||||
const next = new Set(closedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
closedIds.value = next;
|
||||
}
|
||||
|
||||
interface DeptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
parentName: string;
|
||||
level: number;
|
||||
isStudent: boolean;
|
||||
memberCount: number;
|
||||
memberNames: string[];
|
||||
kidCount: number;
|
||||
open: boolean;
|
||||
no: number;
|
||||
raw: any;
|
||||
/**
|
||||
* 子级只给自己用。
|
||||
* ⚠️ 绝不能叫 children:iView Table 发现行上有 children 就切「树形数据模式」(还要求 row-key),
|
||||
* 会和这里自画的缩进 + 折叠打架,表现是整表空白(关掉包含子组后 children 为空数组才恢复)。
|
||||
*/
|
||||
_kids: DeptRow[];
|
||||
}
|
||||
|
||||
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
|
||||
const deptRows = computed<DeptRow[]>(() => {
|
||||
const school = currentSchool.value;
|
||||
if (!school) return [];
|
||||
const rows: DeptRow[] = [];
|
||||
(school.children || [])
|
||||
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
|
||||
.forEach((t: any) => {
|
||||
(t.children || []).forEach((d: any) => rows.push(toRow(d, t.name, 0)));
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
|
||||
function toRow(d: any, parentName: string, level: number): DeptRow {
|
||||
const kids: any[] = d.children || [];
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
status: d.status ?? 0,
|
||||
parentName,
|
||||
level,
|
||||
isStudent: d.type === STUDENT_GROUP_TYPE,
|
||||
memberCount: Number(d.memberCount ?? 0),
|
||||
memberNames: Array.isArray(d.memberNames) ? d.memberNames : [],
|
||||
// 下级数始终按真实层级给:关闭包含子组时只收起子行,徽标仍要能看出「这个部门有下级」
|
||||
kidCount: kids.length,
|
||||
open: !closedIds.value.has(d.id),
|
||||
no: 0,
|
||||
raw: d,
|
||||
_kids: includeOn.value ? kids.map((k) => toRow(k, d.name, level + 1)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 拍平成可渲染行(按折叠状态裁剪子级),并顺序编号 */
|
||||
const flatRows = computed<DeptRow[]>(() => {
|
||||
const out: DeptRow[] = [];
|
||||
const walk = (list: DeptRow[]) => {
|
||||
list.forEach((row) => {
|
||||
out.push(row);
|
||||
if (row.open && row._kids.length) walk(row._kids);
|
||||
});
|
||||
};
|
||||
walk(deptRows.value);
|
||||
out.forEach((row, i) => (row.no = i + 1));
|
||||
return out;
|
||||
});
|
||||
|
||||
/** iView Table 列定义:层级用「部门名称」列的缩进 + 折叠表达,不用 Table 自带树(避免样式不可控) */
|
||||
const sysColumns: any[] = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "部门名称", slot: "name", width: 236 },
|
||||
{ title: "上级", key: "parentName", width: 130 },
|
||||
{ title: "人员", slot: "members", minWidth: 200 },
|
||||
{ title: "状态", slot: "status", width: 90, align: "center" },
|
||||
];
|
||||
/** 停用行整行灰化 */
|
||||
function rowClassName(row: any) {
|
||||
return row?.status === 1 ? "is-off" : "";
|
||||
}
|
||||
|
||||
/** 人员列:最多平铺 10 个姓名,超出用「+N」提示;已全部显示(≤10 人)时不可点 */
|
||||
const MEMBER_PREVIEW = 10;
|
||||
function memberHead(row: DeptRow) {
|
||||
// 姓名预览还没回来(后端未升级 / 未重启)时先退回人数,避免出现空单元格
|
||||
if (!row.memberNames.length) return `${row.memberCount} 人`;
|
||||
return row.memberNames.slice(0, MEMBER_PREVIEW).join("、");
|
||||
}
|
||||
function memberRest(row: DeptRow) {
|
||||
if (!row.memberNames.length) return 0;
|
||||
return Math.max(0, row.memberCount - Math.min(MEMBER_PREVIEW, row.memberNames.length));
|
||||
}
|
||||
/** 只有列不完时才值得点开弹窗(全在表格里看完了,弹窗没有增量信息) */
|
||||
function memberClickable(row: DeptRow) {
|
||||
return row.memberCount > MEMBER_PREVIEW;
|
||||
}
|
||||
function onMemberClick(row: DeptRow) {
|
||||
if (memberClickable(row)) openMembers(row);
|
||||
}
|
||||
function memberTip(row: DeptRow) {
|
||||
if (!row.memberNames.length) return `共 ${row.memberCount} 人 点击查看全部`;
|
||||
const list = row.memberNames.join("、");
|
||||
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
|
||||
return `共 ${row.memberCount} 人:${list}${more}${memberClickable(row) ? " 点击查看全部" : ""}`;
|
||||
}
|
||||
|
||||
/* ================= 人员弹窗(查看全部,分页) ================= */
|
||||
const memModal = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
deptId: 0,
|
||||
deptName: "",
|
||||
rows: [] as any[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
function openMembers(row: DeptRow) {
|
||||
Object.assign(memModal, {
|
||||
visible: true, loading: true, deptId: row.id, deptName: row.name,
|
||||
rows: [], total: 0, page: 1,
|
||||
});
|
||||
loadMembers();
|
||||
}
|
||||
function onMemPageChange(page: number) {
|
||||
memModal.page = page;
|
||||
loadMembers();
|
||||
}
|
||||
async function loadMembers() {
|
||||
memModal.loading = true;
|
||||
try {
|
||||
const res: any = await Apis.SysUser.list({
|
||||
params: { groupId: memModal.deptId, pageIndex: memModal.page, pageSize: memModal.pageSize },
|
||||
cacheFor: null,
|
||||
});
|
||||
memModal.rows = res?.content ?? [];
|
||||
memModal.total = Number(res?.total ?? 0);
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载部门人员失败");
|
||||
memModal.rows = [];
|
||||
memModal.total = 0;
|
||||
} finally {
|
||||
memModal.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前学校的「包含子组」:读学校节点上的 includeChildren(1=含子组 / 0=仅本级)。
|
||||
* 历史数据或旧库该列为 null 时按「含子组」兜底,与升级前表现一致。
|
||||
*/
|
||||
const includeOn = computed(() => {
|
||||
const school = currentSchool.value;
|
||||
return school ? school.includeChildren !== 0 : false;
|
||||
});
|
||||
|
||||
/* ================= 数据加载 ================= */
|
||||
|
||||
/** 树数据源:取全量树存起来给右侧用,树上只渲染到学校层 */
|
||||
async function treeApi() {
|
||||
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||
fullTree.value = cloneDeep(full);
|
||||
return pruneToSchoolLevel(full);
|
||||
}
|
||||
|
||||
/** 剪枝:学校节点保留但不再下钻(超管视角树只到学校层) */
|
||||
function pruneToSchoolLevel(list: any[]): any[] {
|
||||
return (list || []).map((n: any) => {
|
||||
const node: any = { ...n };
|
||||
if (node.type === SCHOOL_GROUP_TYPE) {
|
||||
delete node.children;
|
||||
return node;
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length) {
|
||||
node.children = pruneToSchoolLevel(node.children);
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
function findNodeById(list: any[], id?: number | null): any {
|
||||
if (id == null) return null;
|
||||
for (const n of list || []) {
|
||||
if (Number(n.id) === Number(id)) return n;
|
||||
const hit = findNodeById(n.children || [], id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ================= 交互 ================= */
|
||||
|
||||
function onTreeChange(node: any) {
|
||||
selectedNode.value = node || {};
|
||||
if (node?.type === SCHOOL_GROUP_TYPE) {
|
||||
currentSchoolId.value = node.id;
|
||||
closedIds.value = new Set();
|
||||
} else if (node?.id) {
|
||||
// 点到中间层(区域 / 学校组等)不作为当前学校
|
||||
currentSchoolId.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换「包含子组」:落库到学校节点,成功后就地更新,避免整树重绘丢失选中态 */
|
||||
async function toggleInclude() {
|
||||
const school = currentSchool.value;
|
||||
if (!school?.id) return;
|
||||
const next = !includeOn.value;
|
||||
try {
|
||||
await Apis.SysGroup.setIncludeChildren({
|
||||
pathParams: { id: school.id },
|
||||
data: { includeChildren: next },
|
||||
});
|
||||
school.includeChildren = next ? 1 : 0;
|
||||
closedIds.value = new Set();
|
||||
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
|
||||
} catch (e: any) {
|
||||
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
|
||||
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
|
||||
console.warn(e?.message || "设置包含子组失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onToggle(dept: any) {
|
||||
if (!dept?.id) return;
|
||||
if (dept.status === 1) {
|
||||
doEnable(dept);
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "停用部门",
|
||||
msg: `确定停用部门【${dept.name}】?`,
|
||||
info: "停用只影响「选」(新建时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
|
||||
async onOk() {
|
||||
await doDisable(dept);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function doDisable(dept: any) {
|
||||
try {
|
||||
await Apis.SysGroup.disable({ pathParams: { id: dept.id } });
|
||||
Message.success("已停用");
|
||||
await syncTreeData();
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "停用失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function doEnable(dept: any) {
|
||||
try {
|
||||
await Apis.SysGroup.enable({ pathParams: { id: dept.id } });
|
||||
Message.success("已启用");
|
||||
await syncTreeData();
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "启用失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默同步组树数据:只替换右侧数据源,**不重建左侧 zTree**。<br>
|
||||
* 启停改的是部门自身状态,而树只渲染到学校层,部门节点根本不在树上——
|
||||
* 走 refreshTree 会让整棵树重绘、展开态与滚动位置一起丢失。
|
||||
*/
|
||||
async function syncTreeData() {
|
||||
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||
fullTree.value = cloneDeep(full);
|
||||
}
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
watch(() => store.roleLoaded, (loaded) => {
|
||||
if (!loaded || !isSchoolMode.value) return;
|
||||
loadSchoolScope();
|
||||
}, { immediate: true });
|
||||
|
||||
/** 学校管理员:GetTree 返回的就是各学校子树(多校 = 多根) */
|
||||
async function loadSchoolScope() {
|
||||
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||
fullTree.value = cloneDeep(full);
|
||||
const ids = schoolIds.value;
|
||||
if (ids.length) {
|
||||
currentSchoolId.value = ids[0];
|
||||
} else if (full.length) {
|
||||
currentSchoolId.value = full[0]?.id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-manage-page {
|
||||
/* 骨架交由主题 .tree-wrapper(flex + gap 16 + padding 16)+ .main-container(白卡);
|
||||
这里只声明两卡等高,间距/留白一律不覆盖 */
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* 树:外层不滚(滚动交给内部 zTree),否则内容未超高也会因 1~2px 溢出出现滚动条 */
|
||||
.dept-manage-tree {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.tree-inner {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
|
||||
.dept-manage-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-caption {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e2635);
|
||||
}
|
||||
|
||||
/* 标题行右侧:包含子组 + 来源切换,整体右对齐;「包含子组」放左边,切视图时 tab 位置不跳 */
|
||||
.title-tail {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.school-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
/* 来源切换:同页两个视图,不新增菜单项 */
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: #f2f4f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 3px 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 18px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #5a6478);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab.is-active {
|
||||
background: var(--panel, #fff);
|
||||
color: var(--brand, #577afb);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
|
||||
}
|
||||
|
||||
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
|
||||
.usage-note {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 空态 / 无权限等临时提示 */
|
||||
.state-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dept-loading {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding-top: 80px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
/* 面板头的「包含子组」开关 */
|
||||
.inc-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2, #5a6478);
|
||||
}
|
||||
|
||||
/* 「包含子组」开关:面板头与 zTree 树上共用同一套样式。
|
||||
树上的控件是运行时创建的 DOM,没有 scoped 标记,只能用 :deep 命中 */
|
||||
:deep(.sw-mini) {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: #d3d9e6;
|
||||
transition: background .15s;
|
||||
vertical-align: middle;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .2);
|
||||
transition: left .15s;
|
||||
}
|
||||
|
||||
&.is-on {
|
||||
background: var(--brand, #577afb);
|
||||
|
||||
&::after {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 部门表格(iView Table) ================= */
|
||||
.row-sum {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
/* 表格本体外观全部交给 iView:只补单元格内部的排版细节 */
|
||||
.dept-itable {
|
||||
:deep(.ivu-table-cell) {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
:deep(.ivu-table-row.is-off td) {
|
||||
background: #fcfcfd;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
:deep(.ivu-table-row.is-off:hover td) {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
}
|
||||
|
||||
.nmcell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nmcell .tw {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
font-size: 9px;
|
||||
color: #aeb6c2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nmcell .tw.blank {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.nmcell .nm {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nmcell .nm.root {
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e2635);
|
||||
}
|
||||
|
||||
.nmcell .cnt {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 5px;
|
||||
line-height: 16px;
|
||||
border-radius: 4px;
|
||||
background: #f1f3f7;
|
||||
color: #8a94a6;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.nmcell .cnt.grey {
|
||||
background: #f6f7fa;
|
||||
color: #a3adbd;
|
||||
}
|
||||
|
||||
/* 人员列:姓名区可截断,「+N」固定不收缩(名字太长时也得看得出还有没列完的人) */
|
||||
.mem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text-2, #5a6478);
|
||||
}
|
||||
|
||||
.mem .names {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mem .more {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
/* 只有超过 10 人才可点(全部列完再弹窗没有增量信息) */
|
||||
.mem-cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mem-cell:hover .names {
|
||||
color: var(--brand, #577afb);
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dashed;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.mem-plain {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-3, #98a1b2);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* 状态开关:复用「包含子组」那套胶囊;表格里要跟文字垂直居中 */
|
||||
.table-foot {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 2px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
/* ================= 人员弹窗 ================= */
|
||||
.mem-modal-body {
|
||||
max-height: 380px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mem-empty {
|
||||
padding: 26px 0;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.mem-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
padding: 0 4px;
|
||||
border-bottom: 1px solid #f2f5f9;
|
||||
font-size: 12.5px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mname {
|
||||
color: var(--text, #1e2635);
|
||||
}
|
||||
|
||||
.macc {
|
||||
margin-left: auto;
|
||||
color: var(--text-3, #98a1b2);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mem-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -352,8 +352,8 @@ onMounted(async () => {
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
padding: 8px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -363,12 +363,12 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.ivu-icon {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||
<template #default="{ checkList }">
|
||||
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
|
||||
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Message, Tag, Badge } from "view-ui-plus";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
@@ -70,10 +70,11 @@ import EditUserContainer from './user/EditUserContainer.vue';
|
||||
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
||||
import BatchImportUser from './user/BatchImportUser.vue';
|
||||
import UserSort from './user/UserSort.vue';
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
defineOptions({ name: "GroupUser" });
|
||||
|
||||
const { $utils, $conf } = useCurrentInstance();
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const treeApi = () => Apis.SysGroup.getTree();
|
||||
|
||||
@@ -146,8 +147,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
|
||||
|
||||
/* ================= 表格列 ================= */
|
||||
const columns = [
|
||||
{ type: 'selection', width: 55, align: 'center' },
|
||||
@@ -164,49 +163,30 @@ const columns = [
|
||||
{
|
||||
title: '状态', key: 'disabled', width: 130, align: 'center',
|
||||
render: (h: any, { row }: any) => {
|
||||
if (row.disabled) return h('Tag', { color: 'default' }, '禁用');
|
||||
// Vue3 渲染函数须 import 真组件:h('Tag') 会建未知原生元素(无样式不渲染)
|
||||
// 描边徽标挂主题令牌(.tag-outline,见 main.scss):预设色 border prop 渲染仍实心,弃用
|
||||
if (row.disabled) return h(Tag, { class: 'tag-outline tag-outline-muted' }, () => '禁用');
|
||||
if (row.groupStatus === 1) {
|
||||
// 正常 Tag + 转出徽标(view-ui-plus Badge status 模式:圆点 + 文本)
|
||||
// 正常徽标 + 转出角标(view-ui-plus Badge status 模式:圆点 + 文本)
|
||||
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
|
||||
h('Tag', { color: 'success' }, '正常'),
|
||||
h('Badge', { props: { status: 'warning', text: '转出' } }),
|
||||
h(Tag, { class: 'tag-outline' }, () => '正常'),
|
||||
h(Badge, { status: 'warning', text: '转出' }),
|
||||
]);
|
||||
}
|
||||
return h('Tag', { color: 'success' }, '正常');
|
||||
return h(Tag, { class: 'tag-outline' }, () => '正常');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
||||
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
||||
render: (h: any, { row }: any) => {
|
||||
const opList: any[] = [
|
||||
h('a', {
|
||||
style: { marginRight: '8px', color: '#577afb' },
|
||||
onClick: () => handleEdit(row),
|
||||
}, '编辑'),
|
||||
];
|
||||
// 仅「单组浏览」(withSub=0)时开放排序:此时每个用户都直接属于当前组,排序号语义明确;
|
||||
// 含子组搜索(withSub=1)下用户可能属于子组,排序号归属不清,故隐藏。
|
||||
if (dataParams.value.withSub === 0) {
|
||||
opList.push(
|
||||
h('a', {
|
||||
style: { marginRight: '8px', color: '#2d8cf0' },
|
||||
onClick: () => openSort(row),
|
||||
}, '排序'),
|
||||
);
|
||||
}
|
||||
opList.push(
|
||||
h('a', {
|
||||
style: { color: '#ed4014' },
|
||||
onClick: () => handleDelete(row),
|
||||
}, '删除'),
|
||||
);
|
||||
return h('div', opList);
|
||||
},
|
||||
},
|
||||
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化;
|
||||
// 排序仅「单组浏览」开放——withSub 是与行数据无关的全局模式开关,用 when 整组隐藏合规
|
||||
actionColumn([
|
||||
{ key: 'edit', label: '编辑', on: (r: any) => handleEdit(r) },
|
||||
{ key: 'sort', label: '排序', on: (r: any) => openSort(r), when: () => dataParams.value.withSub === 0 },
|
||||
{ key: 'del', label: '删除', on: (r: any) => handleDelete(r), danger: true },
|
||||
], { mode: 'text', width: 180, fixed: 'right' }),
|
||||
];
|
||||
|
||||
/* ================= 导出组用户 ================= */
|
||||
@@ -368,10 +348,6 @@ function onSortSaved() {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.group-user-page .page-panel {
|
||||
left: 348px !important;
|
||||
}
|
||||
|
||||
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
|
||||
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
|
||||
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
|
||||
|
||||
@@ -158,10 +158,10 @@ async function handlePreview() {
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
|
||||
@@ -26,7 +26,7 @@
|
||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||
</div>
|
||||
|
||||
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||
@@ -35,15 +35,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import OapiLogDetailModal from './OapiLogDetailModal.vue';
|
||||
import Utils from '@/utils';
|
||||
import { actionColumn } from '@/utils/tableActions';
|
||||
|
||||
defineOptions({ name: 'OapiLogList' });
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
interface DataParams {
|
||||
@@ -98,8 +96,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
||||
|
||||
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||
1: { text: '重要', className: 'text-error' },
|
||||
2: { text: '次要', className: 'text-warning' },
|
||||
@@ -164,18 +160,10 @@ const columns = [
|
||||
width: 180,
|
||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right' as const,
|
||||
render: (h: any, { row }: any) =>
|
||||
h('a', {
|
||||
style: { color: '#577afb' },
|
||||
onClick: () => openDetail(row),
|
||||
}, '查看详情'),
|
||||
},
|
||||
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||
actionColumn([
|
||||
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||
];
|
||||
|
||||
const detailVisible = ref(false);
|
||||
|
||||
@@ -150,10 +150,10 @@ async function handlePreview() {
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
||||
@@ -27,7 +27,7 @@
|
||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||
</div>
|
||||
|
||||
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||
@@ -36,15 +36,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
||||
import Utils from '@/utils';
|
||||
import { actionColumn } from '@/utils/tableActions';
|
||||
|
||||
defineOptions({ name: 'OperationLogList' });
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
interface DataParams {
|
||||
@@ -99,8 +97,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
||||
|
||||
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||
1: { text: '重要', className: 'text-error' },
|
||||
2: { text: '次要', className: 'text-warning' },
|
||||
@@ -160,23 +156,15 @@ const columns = [
|
||||
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作时间ed',
|
||||
title: '操作时间',
|
||||
key: 'createTime',
|
||||
width: 180,
|
||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right' as const,
|
||||
render: (h: any, { row }: any) =>
|
||||
h('a', {
|
||||
style: { color: '#577afb' },
|
||||
onClick: () => openDetail(row),
|
||||
}, '查看详情'),
|
||||
},
|
||||
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||
actionColumn([
|
||||
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||
];
|
||||
|
||||
const detailVisible = ref(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
|
||||
@@ -27,7 +27,7 @@
|
||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||
</div>
|
||||
|
||||
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||
@@ -37,15 +37,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Message } from 'view-ui-plus';
|
||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import Utils from '@/utils';
|
||||
import PushLogDetailModal from './PushLogDetailModal.vue';
|
||||
import { actionColumn } from '@/utils/tableActions';
|
||||
|
||||
defineOptions({ name: 'PushLog' });
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
const interfaceOptions = [
|
||||
@@ -113,8 +111,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
||||
|
||||
const columns = [
|
||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||||
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
|
||||
@@ -163,23 +159,11 @@ const columns = [
|
||||
align: 'right',
|
||||
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
align: 'left',
|
||||
render: (h: any, { row }: any) => {
|
||||
const actions = [
|
||||
h('a', { style: { color: '#577afb', marginRight: '12px' }, onClick: () => openDetail(row) }, '查看详情'),
|
||||
];
|
||||
if (row.success !== 1) {
|
||||
actions.push(
|
||||
h('a', { style: { color: '#ed4014' }, onClick: () => handleRetry(row) }, '重试')
|
||||
);
|
||||
}
|
||||
return h('span', actions);
|
||||
},
|
||||
},
|
||||
// 重试按行数据(成功与否)变化 → disabledWhen 置灰保位(§5.2 按钮位置稳定性红线),禁止 when 隐藏
|
||||
actionColumn([
|
||||
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||
{ key: 'retry', label: '重试', on: (r: any) => handleRetry(r), danger: true, disabledWhen: (r: any) => r.success === 1 },
|
||||
], { mode: 'text', width: 150 }),
|
||||
];
|
||||
|
||||
const detailVisible = ref(false);
|
||||
@@ -225,12 +209,4 @@ loadApps();
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #19be6b;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #ed4014;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -169,10 +169,10 @@ function formatTime(val: any): string {
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ type AppModuleVo = {
|
||||
checked?: boolean;
|
||||
children?: AppModuleVo[];
|
||||
};
|
||||
type AppItem = { id?: number; name?: string; sortNo?: number; modules?: AppModuleVo[] };
|
||||
type AppItem = { id?: number; name?: string; sortNo?: number; checked?: boolean; modules?: AppModuleVo[] };
|
||||
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
|
||||
type AuthzTreeNode = {
|
||||
id: string;
|
||||
@@ -118,12 +118,16 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
||||
}
|
||||
function isAppChecked(app: AppItem): boolean {
|
||||
const modules = app.modules || [];
|
||||
if (modules.length === 0) return false;
|
||||
return modules.every((m) => isModuleAllChecked(m));
|
||||
// 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
|
||||
if (modules.length === 0) return !!app.checked;
|
||||
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
|
||||
return modules.some((m) => isModuleAnyChecked(m));
|
||||
}
|
||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
||||
if (!node.checked) return false;
|
||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
||||
|
||||
/** 自身或任一子孙被勾选 */
|
||||
function isModuleAnyChecked(node: AppModuleVo): boolean {
|
||||
if (node.checked) return true;
|
||||
return (node.children || []).some((c) => isModuleAnyChecked(c));
|
||||
}
|
||||
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
|
||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||
@@ -132,7 +136,7 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||
id: `mod_${m.id}`,
|
||||
name: String(m.name || ""),
|
||||
open: true,
|
||||
checked: !!m.checked,
|
||||
checked: isModuleAnyChecked(m),
|
||||
nodeType: "module",
|
||||
rawId: Number(m.id),
|
||||
appId,
|
||||
@@ -255,7 +259,12 @@ function onTreeCheck(node: any) {
|
||||
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
|
||||
const app = findLocalApp(node.appId);
|
||||
if (!app) return;
|
||||
syncAppModulesFromTree(app, node);
|
||||
if ((app.modules || []).length === 0) {
|
||||
// 无子模块应用:没有模块明细可同步,直接记整应用勾选态
|
||||
app.checked = !!node.checked;
|
||||
} else {
|
||||
syncAppModulesFromTree(app, node);
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
@@ -265,7 +274,14 @@ const saveNow = async () => {
|
||||
const moduleIds: number[] = [];
|
||||
for (const cat of categoryList.value || []) {
|
||||
for (const app of cat.apps || []) {
|
||||
walkModules(app.modules || [], (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
|
||||
const mods = app.modules || [];
|
||||
if (mods.length === 0) {
|
||||
// 无子模块应用:用 -appId 哨兵表示「整应用授权」(后端 AppLevelModuleId 约定),
|
||||
// 否则这类应用永远收集不到 id、保存时被整表覆盖清掉。
|
||||
if (app.checked) moduleIds.push(-Number(app.id));
|
||||
continue;
|
||||
}
|
||||
walkModules(mods, (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -299,7 +315,7 @@ watch(
|
||||
.role-authz-tree {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
@@ -343,7 +359,7 @@ watch(
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -441,6 +457,13 @@ watch(
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 父级勾选样式(2026-10-20):把 zTree 半选状态位 true_part 的图标换成 true_full 的蓝底勾,
|
||||
让「只要有一个子节点被选中」的父节点统一呈现为选中态(与组授权页口径一致)。 */
|
||||
:deep(.ztree li span.button.chk.checkbox_true_part),
|
||||
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
|
||||
background-position: -26px -5px;
|
||||
}
|
||||
|
||||
:deep(.ztree li a) {
|
||||
max-width: calc(100% - 30px);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page-wrapper role-authz-wrapper">
|
||||
<div class="page-wrapper tree-wrapper role-authz-wrapper">
|
||||
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
||||
<div class="role-list-panel">
|
||||
<div class="tree-container role-list-panel">
|
||||
<div class="role-filter">
|
||||
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
||||
@on-change="onFilterChange">
|
||||
@@ -18,7 +18,7 @@
|
||||
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
||||
<div class="role-name">{{ role.name }}</div>
|
||||
<div class="role-app">
|
||||
<Tag v-if="role.allAppFlag" color="blue" size="small">全应用</Tag>
|
||||
<Tag v-if="role.allAppFlag" class="tag-brand" border size="small">全应用</Tag>
|
||||
<span v-else>{{ role.appName || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,12 +31,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
||||
<div class="authz-panel">
|
||||
<div class="main-container authz-panel">
|
||||
<div v-if="selectedRole" class="authz-inner">
|
||||
<div class="authz-bar">
|
||||
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
||||
<span class="authz-app">
|
||||
<Tag v-if="selectedRole.allAppFlag" color="blue">全应用</Tag>
|
||||
<Tag v-if="selectedRole.allAppFlag" class="tag-brand" border>全应用</Tag>
|
||||
<span v-else>{{ selectedRole.appName || "-" }}</span>
|
||||
</span>
|
||||
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
||||
@@ -152,19 +152,12 @@ function goManage() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-authz-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* 通道 tree-wrapper 骨架:外边界 16 / 两卡间隙 16 / 等高 stretch,高度对齐列表页 */
|
||||
.role-list-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #ebedf0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -196,12 +189,12 @@ function goManage() {
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #b8c6ff;
|
||||
border-color: var(--brand-2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #577afb;
|
||||
background: #f3f6ff;
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,18 +228,21 @@ function goManage() {
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
|
||||
.authz-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.authz-inner {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -255,10 +251,18 @@ function goManage() {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.authz-title b {
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* 主题色描边徽标(iView color 预设是写死蓝,改挂通道令牌随档变化) */
|
||||
.tag-brand {
|
||||
color: var(--brand) !important;
|
||||
border: 1px solid var(--brand) !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.authz-app {
|
||||
@@ -268,9 +272,13 @@ function goManage() {
|
||||
|
||||
.authz-tabs {
|
||||
margin-top: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.authz-tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -158,10 +158,10 @@ function goAuthz() {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
|
||||
&:hover {
|
||||
color: #6c91f0;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
const props = defineProps<{
|
||||
roleId: number;
|
||||
@@ -44,10 +42,10 @@ const columns = [
|
||||
{ title: "账号", key: "account", width: 110 },
|
||||
{ title: "姓名", key: "realName", width: 90 },
|
||||
{ title: "部门", key: "groupPath", minWidth: 200 },
|
||||
{
|
||||
title: "操作", key: "action", width: 80, align: "center",
|
||||
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
|
||||
},
|
||||
// UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
|
||||
actionColumn([
|
||||
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
|
||||
], { mode: "text", width: 80 }),
|
||||
];
|
||||
|
||||
async function loadUsers() {
|
||||
|
||||
+26
-9
@@ -99,15 +99,32 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 8130,
|
||||
// proxy: {
|
||||
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标
|
||||
// // "/api": {
|
||||
// // target: "http://localhost:8064/",
|
||||
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse",
|
||||
// // changeOrigin: true, //需要代理跨域
|
||||
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为''
|
||||
// // },
|
||||
// },
|
||||
proxy: {
|
||||
// 主题通道本地联调(唯一必需项):dev 下 index.html 的 loader 走同源相对(见其注释的优先级四档),
|
||||
// manifest/CSS 经此代理到中央通道;changeOrigin 顺带把 Host 改写成目标域,Caddy 按 Host 匹配站点。
|
||||
// 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
|
||||
// YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target。prod 同源用不到这段。
|
||||
"/static": {
|
||||
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 后端接口同源代理:alova 的 baseURL 在非 mock 时取 .env 的 VITE_SERVICE_HOST,
|
||||
// 想"只换后端不改 .env"就把 VITE_SERVICE_HOST 留空 → 请求落同源 /api、/mobiapi → 由此处代理决定去向。
|
||||
// 本地后端(默认):npm run dev
|
||||
// 线上后端: YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
|
||||
// (changeOrigin 已改写 Host,Caddy 按 Host 匹配;但 CAS 登录 cookie 属
|
||||
// yunhaixingtong 域,localhost 侧带不上 → 未登录接口会被 302 到 SSO,
|
||||
// 需要真数据时优先配合 VITE_IS_MOCK=false + 本地后端,或先在同浏览器登录线上)
|
||||
// mock 模式(VITE_IS_MOCK=true)下已注册的接口由 vite-plugin-mock 直接应答,未注册的仍透传到本 target。
|
||||
"/api": {
|
||||
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/mobiapi": {
|
||||
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// css预处理器
|
||||
css: {
|
||||
|
||||
Reference in New Issue
Block a user