Compare commits
23
Commits
0a93441f64
..
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 |
@@ -19,7 +19,22 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- name: 安装 git / curl / zip(alpine)
|
||||||
run: apk add --no-cache git curl zip
|
run: |
|
||||||
|
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 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -38,21 +53,29 @@ jobs:
|
|||||||
- name: 构建前端(node 容器)
|
- name: 构建前端(node 容器)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
docker exec frontbuild sh -c '
|
# 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
|
set -e
|
||||||
cd /build
|
cd /build
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
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 ci
|
||||||
npm run build
|
npm run build
|
||||||
'
|
'
|
||||||
echo "=== dist 产物 ==="
|
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 到宿主工作目录
|
# 从容器 cp dist 到宿主工作目录
|
||||||
docker cp frontbuild:/build/dist ./dist
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
docker rm -f frontbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
echo "BUILD CHECK OK"
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -30,8 +30,28 @@ export default {
|
|||||||
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
|
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
|
||||||
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
|
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
|
||||||
'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{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.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
|
||||||
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
|
'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.test': ['POST', '/api/push/Test'],
|
||||||
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
||||||
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
||||||
|
|||||||
Vendored
+623
@@ -148,6 +148,59 @@ export interface SysGroupRequest {
|
|||||||
type: number;
|
type: number;
|
||||||
sort?: 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 {
|
export interface PushTestRequest {
|
||||||
appId?: number;
|
appId?: number;
|
||||||
interfacePath?: string;
|
interfacePath?: string;
|
||||||
@@ -244,6 +297,14 @@ export interface SysGroupVo {
|
|||||||
name?: string;
|
name?: string;
|
||||||
type?: number;
|
type?: number;
|
||||||
sort?: 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[];
|
children?: SysGroupVo[];
|
||||||
}
|
}
|
||||||
export interface AppItem {
|
export interface AppItem {
|
||||||
@@ -1538,6 +1599,81 @@ declare global {
|
|||||||
>(
|
>(
|
||||||
config: Config
|
config: Config
|
||||||
): Alova2Method<null, 'SysGroup.sysGroupRemove', 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>;
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
@@ -1705,6 +1841,493 @@ declare global {
|
|||||||
config?: Config
|
config?: Config
|
||||||
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', 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: {
|
PushSubscription: {
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { UserRoleEnum } from "@/types";
|
|||||||
* 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
|
* 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
|
||||||
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
|
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
|
||||||
* 4. 日志管理 —— 仅超管(父目录)
|
* 4. 日志管理 —— 仅超管(父目录)
|
||||||
|
* 5. 基础设置 —— 非业务域的基础配置合集(当前:部门管理),后续字典/参数/模板等杂项归此
|
||||||
|
*
|
||||||
|
* 菜单不渲染图标:本工程侧边栏只输出文字,节点无需 icon 字段。
|
||||||
*
|
*
|
||||||
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
||||||
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
||||||
@@ -64,6 +67,18 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
|
|||||||
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
|
{ 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));
|
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 |
@@ -98,20 +98,88 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||||
.table-spin-load {
|
.table-spin-load {
|
||||||
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
margin: 0 auto;
|
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 {
|
.ivu-table-cell-tree {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 2px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ function getNodeById(id: number) {
|
|||||||
return refMyTree.value?.getNodeById(id);
|
return refMyTree.value?.getNodeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在指定节点后追加自定义控件(见 MyTree.attachNodeControl) */
|
||||||
|
function attachNodeControl(id: number, className: string, html: string) {
|
||||||
|
return refMyTree.value?.attachNodeControl(id, className, html);
|
||||||
|
}
|
||||||
|
|
||||||
function getCheckedNodes() {
|
function getCheckedNodes() {
|
||||||
return refMyTree.value?.getCheckedNodes() || [];
|
return refMyTree.value?.getCheckedNodes() || [];
|
||||||
}
|
}
|
||||||
@@ -257,6 +262,7 @@ defineExpose({
|
|||||||
removeNode,
|
removeNode,
|
||||||
renameNode,
|
renameNode,
|
||||||
getNodeById,
|
getNodeById,
|
||||||
|
attachNodeControl,
|
||||||
getCheckedNodes,
|
getCheckedNodes,
|
||||||
checkNodeById,
|
checkNodeById,
|
||||||
addNote,
|
addNote,
|
||||||
|
|||||||
@@ -222,6 +222,33 @@ export default {
|
|||||||
if (on) a.addClass('ztree-search-highlight');
|
if (on) a.addClass('ztree-search-highlight');
|
||||||
else a.removeClass('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() {
|
clearHighlight() {
|
||||||
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||||
this.highlightNodes = [];
|
this.highlightNodes = [];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<div class="table-region" ref="regionRef">
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
<template #loading>
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<div class="table-spin-load">加载中</div>
|
<template #loading>
|
||||||
</template>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
</template>
|
||||||
<slot :name="slotName" v-bind="slotProps" />
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
</template>
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
</Table>
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl align-middle">
|
<div class="fl ml-15 align-middle">
|
||||||
<slot :checkList="selectList" />
|
<slot :checkList="selectList" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useSlots } from 'vue'
|
import { computed, ref, useSlots, watch } from 'vue'
|
||||||
import Utils from '@/utils'
|
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||||
import { OperatorEnum } from '@/types'
|
import { OperatorEnum } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
const slots = useSlots()
|
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 tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||||
|
|
||||||
const selectList = ref<number[]>([])
|
const selectList = ref<number[]>([])
|
||||||
@@ -68,18 +73,30 @@ const {
|
|||||||
total: res => props.formatResponse(res).total,
|
total: res => props.formatResponse(res).total,
|
||||||
data: res => props.formatResponse(res).list,
|
data: res => props.formatResponse(res).list,
|
||||||
initialPage: 1,
|
initialPage: 1,
|
||||||
initialPageSize: 10,
|
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||||
|
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||||
immediate: props.autoLoad,
|
immediate: props.autoLoad,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||||
|
watch(resp, () => {
|
||||||
|
if (selectList.value.length) {
|
||||||
|
selectList.value = []
|
||||||
|
emit('selection-change', selectList.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
if (!resp.value) return []
|
if (!resp.value) return []
|
||||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPageChange = (p: number) => (page.value = p)
|
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() {
|
function refreshData() {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
import { computed } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -22,7 +22,16 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
const style = computed(() => resolveAvatarStyle());
|
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||||
|
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||||
|
const appearanceTick = ref(0);
|
||||||
|
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||||
|
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
const style = computed(() => {
|
||||||
|
appearanceTick.value;
|
||||||
|
return resolveAvatarStyle();
|
||||||
|
});
|
||||||
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
const showText = computed(() => style.value === "text" && !!text.value);
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
|||||||
+25
-14
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 { LayoutData } from "@/types/Basics";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import UserAvatar from "@/components/UserAvatar.vue";
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||||
let props = defineProps<{ layoutData: LayoutData }>();
|
let props = defineProps<{ layoutData: LayoutData }>();
|
||||||
const reload: Function = inject("reload") || new Function("console.error('empty')");
|
const reload: Function = inject("reload") || new Function("console.error('empty')");
|
||||||
|
|
||||||
@@ -13,6 +14,27 @@ const appPanel = ref();
|
|||||||
let route = useRoute();
|
let route = useRoute();
|
||||||
const router = useRouter();
|
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),
|
const isPanel = ref(false),
|
||||||
currPath = ref<string>(""),
|
currPath = ref<string>(""),
|
||||||
navPath = ref<any[]>([]);
|
navPath = ref<any[]>([]);
|
||||||
@@ -167,11 +189,8 @@ function linkHomePage() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-wrap">
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<div class="fl align-middle icon-box">
|
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||||
<div title="首页" class="icon-btn home-btn" @click="linkHomePage">
|
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||||
<SvgIcon name="shouye" :size="22" color="#fff" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="delimiter fl align-middle"></div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||||
<div class="school-bg"></div>
|
<div class="school-bg"></div>
|
||||||
@@ -271,12 +290,4 @@ function linkHomePage() {
|
|||||||
.header-aside .logout svg {
|
.header-aside .logout svg {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.home-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.home-btn svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import PermissionView from "@/views/PermissionView.vue";
|
|||||||
import OperationLogList from "@/views/oplog/OperationLogList.vue";
|
import OperationLogList from "@/views/oplog/OperationLogList.vue";
|
||||||
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
|
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
|
||||||
import PushLog from "@/views/push/PushLog.vue";
|
import PushLog from "@/views/push/PushLog.vue";
|
||||||
|
import DeptManage from "@/views/dept-manage/index.vue";
|
||||||
import NoAuth from "@/views/NoAuth.vue";
|
import NoAuth from "@/views/NoAuth.vue";
|
||||||
|
|
||||||
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
|
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
|
||||||
@@ -105,6 +106,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: PermissionView,
|
component: PermissionView,
|
||||||
meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN },
|
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",
|
path: "/push-log",
|
||||||
name: "PushLog",
|
name: "PushLog",
|
||||||
|
|||||||
@@ -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 { IObj, UseTableSetting } from "@/types/Basics";
|
||||||
import { resolveComponent } from "vue";
|
import { actionIcon, actionText } from "@/utils/tableActions";
|
||||||
export default class Utils {
|
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(
|
public static operatingHandle(
|
||||||
h: any,
|
h: any,
|
||||||
@@ -99,30 +100,10 @@ export default class Utils {
|
|||||||
callback: Function,
|
callback: Function,
|
||||||
disabled: boolean = false,
|
disabled: boolean = false,
|
||||||
) {
|
) {
|
||||||
if (disabled) iconClass += " disabled";
|
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
|
||||||
|
|
||||||
return h(
|
|
||||||
resolveComponent("Tooltip"),
|
|
||||||
{
|
|
||||||
content: info,
|
|
||||||
placement: "bottom",
|
|
||||||
transfer: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => [
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: `operating-btn iconfont ${iconClass}`,
|
|
||||||
onClick: callback,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
|
||||||
public static operatingTextHandle(
|
public static operatingTextHandle(
|
||||||
h: any,
|
h: any,
|
||||||
info: string,
|
info: string,
|
||||||
@@ -130,15 +111,13 @@ export default class Utils {
|
|||||||
callback: Function,
|
callback: Function,
|
||||||
disabled: boolean = false,
|
disabled: boolean = false,
|
||||||
) {
|
) {
|
||||||
if (disabled) className += " disabled";
|
return actionText(h, {
|
||||||
return h(
|
key: info,
|
||||||
"div",
|
label: info,
|
||||||
{
|
danger: (className || "").includes("text-error"),
|
||||||
class: `operating-link ${className}`,
|
on: () => callback(),
|
||||||
onClick: disabled ? null : callback,
|
disabled,
|
||||||
},
|
});
|
||||||
info,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
.school-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
+61
-11
@@ -90,6 +90,10 @@ type AppItem = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
sortNo?: number;
|
sortNo?: number;
|
||||||
|
/** 应用级勾选态:仅当 modules 为空时有意义(对应后端 module_id=0 的整应用授权记录) */
|
||||||
|
checked?: boolean;
|
||||||
|
/** 应用级子组继承:仅当 modules 为空时有意义 */
|
||||||
|
inheritFlag?: boolean;
|
||||||
modules?: AppModuleVo[];
|
modules?: AppModuleVo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,19 +225,22 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
|||||||
|
|
||||||
function isAppChecked(app: AppItem): boolean {
|
function isAppChecked(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
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 {
|
function isAppInherit(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
const modules = app.modules || [];
|
||||||
if (modules.length === 0) return false;
|
if (modules.length === 0) return !!app.inheritFlag;
|
||||||
return modules.every((m) => isModuleAllInherit(m));
|
return modules.every((m) => isModuleAllInherit(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
/** 自身或任一子孙被勾选 */
|
||||||
if (!node.checked) return false;
|
function isModuleAnyChecked(node: AppModuleVo): boolean {
|
||||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
if (node.checked) return true;
|
||||||
|
return (node.children || []).some((c) => isModuleAnyChecked(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModuleAllInherit(node: AppModuleVo): boolean {
|
function isModuleAllInherit(node: AppModuleVo): boolean {
|
||||||
@@ -248,7 +255,8 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
|||||||
id: `mod_${m.id}`,
|
id: `mod_${m.id}`,
|
||||||
name: String(m.name || ""),
|
name: String(m.name || ""),
|
||||||
open: true,
|
open: true,
|
||||||
checked: !!m.checked,
|
// 父模块节点沿用「有子节点选中即选中」的展示口径,与顶层应用节点一致
|
||||||
|
checked: isModuleAnyChecked(m),
|
||||||
inheritFlag: !!m.inheritFlag,
|
inheritFlag: !!m.inheritFlag,
|
||||||
nodeType: "module",
|
nodeType: "module",
|
||||||
rawId: Number(m.id),
|
rawId: Number(m.id),
|
||||||
@@ -423,6 +431,10 @@ function findLocalApp(appId: number): AppItem | null {
|
|||||||
function setLocalAppInherit(appId: number, inherit: boolean) {
|
function setLocalAppInherit(appId: number, inherit: boolean) {
|
||||||
const app = findLocalApp(appId);
|
const app = findLocalApp(appId);
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
if ((app.modules || []).length === 0) {
|
||||||
|
app.inheritFlag = inherit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
walkModules(app.modules || [], (n) => {
|
walkModules(app.modules || [], (n) => {
|
||||||
if (n.checked) n.inheritFlag = inherit;
|
if (n.checked) n.inheritFlag = inherit;
|
||||||
});
|
});
|
||||||
@@ -453,6 +465,33 @@ async function onTreeCheck(node: any) {
|
|||||||
return;
|
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>();
|
const before = new Map<number, boolean>();
|
||||||
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
|
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
|
||||||
@@ -603,14 +642,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -729,13 +768,22 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.open-link {
|
.open-link {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
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 节点行尾的"子组继承"复选框:
|
/* zTree 节点行尾的"子组继承"复选框:
|
||||||
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
||||||
:deep(.inherit-wrap) {
|
:deep(.inherit-wrap) {
|
||||||
@@ -755,7 +803,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ztree li a) {
|
:deep(.ztree li a) {
|
||||||
max-width: calc(100% - 90px);
|
/* 子组继承复选框仅悬停时由 addHoverDom 注入(绝对定位 right:14,占位约 30px),
|
||||||
|
常态无需预留 90px——收窄预留,消除「明明有空间却截断成 ...」 */
|
||||||
|
max-width: calc(100% - 36px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+37
-56
@@ -31,7 +31,7 @@
|
|||||||
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 容器组件:新增 / 编辑 -->
|
<!-- 容器组件:新增 / 编辑 -->
|
||||||
@@ -51,7 +51,6 @@
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
defineOptions({ name: "AppList" });
|
defineOptions({ name: "AppList" });
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import AddAppContainer from "./AddAppContainer.vue";
|
import AddAppContainer from "./AddAppContainer.vue";
|
||||||
import EditAppContainer from "./EditAppContainer.vue";
|
import EditAppContainer from "./EditAppContainer.vue";
|
||||||
@@ -60,9 +59,9 @@ import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
|||||||
import { OperatorEnum } from '@/types';
|
import { OperatorEnum } from '@/types';
|
||||||
import AppSort from './AppSort.vue';
|
import AppSort from './AppSort.vue';
|
||||||
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
|
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
|
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
|
||||||
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
|
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
|
||||||
@@ -146,10 +145,6 @@ function onSortSaved() {
|
|||||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableHeight = ref(
|
|
||||||
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
||||||
const columns = [
|
const columns = [
|
||||||
{ type: "index", title: "序号", width: 70 },
|
{ type: "index", title: "序号", width: 70 },
|
||||||
@@ -174,25 +169,41 @@ const columns = [
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
|
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
|
||||||
},
|
},
|
||||||
{
|
actionColumn(
|
||||||
title: "操作",
|
[
|
||||||
key: "action",
|
|
||||||
align: 'center',
|
|
||||||
width: 280,
|
|
||||||
render: (h: any, { row }: any) => {
|
|
||||||
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
|
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
|
||||||
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
|
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
|
||||||
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
|
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
|
||||||
const base = row.appNature === "BASE";
|
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||||
const actions = [$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row))];
|
{
|
||||||
if (!isSchoolMode.value) {
|
key: "module",
|
||||||
actions.push($utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row), base));
|
label: "模块管理",
|
||||||
actions.push($utils.operatingTextHandle(h, '推送设置', '', () => openPushSetting(row), base));
|
icon: "icon-apps",
|
||||||
actions.push($utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)));
|
on: (r: any) => goModuleManage(r),
|
||||||
}
|
when: () => !isSchoolMode.value,
|
||||||
return actions;
|
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 },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
||||||
@@ -288,7 +299,6 @@ const goModuleManage = (row: any) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 180px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.school-select {
|
.school-select {
|
||||||
@@ -310,7 +320,7 @@ const goModuleManage = (row: any) => {
|
|||||||
border-top: 1px solid #eeeeee;
|
border-top: 1px solid #eeeeee;
|
||||||
|
|
||||||
.ivu-btn-text {
|
.ivu-btn-text {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,37 +341,8 @@ const goModuleManage = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
li.active {
|
li.active {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
border-color: #577afb;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -27,14 +27,14 @@
|
|||||||
<Input placeholder="请输入模块名称" v-model="form.name" />
|
<Input placeholder="请输入模块名称" v-model="form.name" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="模块路径:" prop="path">
|
<FormItem label="模块路径:" prop="path">
|
||||||
<Input placeholder="请输入模块路径" v-model="form.path" />
|
<Input :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入模块路径'" v-model="form.path" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="模块图标:" prop="icon">
|
<FormItem label="模块图标:" prop="icon">
|
||||||
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
|
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
|
||||||
v-model="form.icon" clearable />
|
v-model="form.icon" clearable />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="自定义ID:" prop="customId">
|
<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>
|
||||||
<FormItem label="排序号:" prop="sortNo">
|
<FormItem label="排序号:" prop="sortNo">
|
||||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.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 { debounce } from "lodash";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
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: "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: "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: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
||||||
{
|
// UserBase 恒文字链;3 动作 ≤ maxInline+1 → 全直出(与改前形态一致),列宽 220 → 180
|
||||||
title: "操作",
|
actionColumn([
|
||||||
key: "action",
|
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||||
width: 220,
|
{
|
||||||
align: "center",
|
key: "add",
|
||||||
render: (h: any, { row }: any) => {
|
label: "添加子模块",
|
||||||
const actions: any[] = [
|
icon: "icon-tianjia",
|
||||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
on: (r: any) => handleAddChild(r),
|
||||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
when: (r: any) => r.level < 5,
|
||||||
];
|
|
||||||
if (row.level < 5) {
|
|
||||||
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
|
|
||||||
}
|
|
||||||
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
|
|
||||||
},
|
},
|
||||||
},
|
{ 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,
|
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 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(() => {
|
const modalTitle = computed(() => {
|
||||||
if (currentId.value != null) return '编辑模块';
|
if (currentId.value != null) return '编辑模块';
|
||||||
return openingIsChild.value ? '新增子模块' : '新增模块';
|
return openingIsChild.value ? '新增子模块' : '新增模块';
|
||||||
@@ -264,7 +261,7 @@ async function onRawSave() {
|
|||||||
data: {
|
data: {
|
||||||
appId: appId.value,
|
appId: appId.value,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
path: form.value.path,
|
path: form.value.path?.trim() || undefined,
|
||||||
icon: form.value.icon?.trim() || undefined,
|
icon: form.value.icon?.trim() || undefined,
|
||||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||||
parentId: form.value.parentId ?? undefined,
|
parentId: form.value.parentId ?? undefined,
|
||||||
@@ -278,8 +275,8 @@ async function onRawSave() {
|
|||||||
id: currentId.value,
|
id: currentId.value,
|
||||||
appId: appId.value,
|
appId: appId.value,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
path: form.value.path,
|
path: form.value.path?.trim() || undefined,
|
||||||
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标能生效
|
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
|
||||||
icon: form.value.icon?.trim() || undefined,
|
icon: form.value.icon?.trim() || undefined,
|
||||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||||
parentId: form.value.parentId ?? undefined,
|
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;
|
padding-bottom: 10px;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
height: 38px;
|
height: 34px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@@ -363,12 +363,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ivu-icon {
|
.ivu-icon {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #default="{ checkList }">
|
<template #default="{ checkList }">
|
||||||
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
|
<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>
|
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from "vue";
|
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 useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
@@ -70,10 +70,11 @@ import EditUserContainer from './user/EditUserContainer.vue';
|
|||||||
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
||||||
import BatchImportUser from './user/BatchImportUser.vue';
|
import BatchImportUser from './user/BatchImportUser.vue';
|
||||||
import UserSort from './user/UserSort.vue';
|
import UserSort from './user/UserSort.vue';
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
defineOptions({ name: "GroupUser" });
|
defineOptions({ name: "GroupUser" });
|
||||||
|
|
||||||
const { $utils, $conf } = useCurrentInstance();
|
const { $conf } = useCurrentInstance();
|
||||||
|
|
||||||
const treeApi = () => Apis.SysGroup.getTree();
|
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 = [
|
const columns = [
|
||||||
{ type: 'selection', width: 55, align: 'center' },
|
{ type: 'selection', width: 55, align: 'center' },
|
||||||
@@ -164,49 +163,30 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: '状态', key: 'disabled', width: 130, align: 'center',
|
title: '状态', key: 'disabled', width: 130, align: 'center',
|
||||||
render: (h: any, { row }: any) => {
|
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) {
|
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' }, [
|
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
|
||||||
h('Tag', { color: 'success' }, '正常'),
|
h(Tag, { class: 'tag-outline' }, () => '正常'),
|
||||||
h('Badge', { props: { status: 'warning', text: '转出' } }),
|
h(Badge, { status: 'warning', text: '转出' }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return h('Tag', { color: 'success' }, '正常');
|
return h(Tag, { class: 'tag-outline' }, () => '正常');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
||||||
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化;
|
||||||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
// 排序仅「单组浏览」开放——withSub 是与行数据无关的全局模式开关,用 when 整组隐藏合规
|
||||||
render: (h: any, { row }: any) => {
|
actionColumn([
|
||||||
const opList: any[] = [
|
{ key: 'edit', label: '编辑', on: (r: any) => handleEdit(r) },
|
||||||
h('a', {
|
{ key: 'sort', label: '排序', on: (r: any) => openSort(r), when: () => dataParams.value.withSub === 0 },
|
||||||
style: { marginRight: '8px', color: '#577afb' },
|
{ key: 'del', label: '删除', on: (r: any) => handleDelete(r), danger: true },
|
||||||
onClick: () => handleEdit(row),
|
], { mode: 'text', width: 180, fixed: 'right' }),
|
||||||
}, '编辑'),
|
|
||||||
];
|
|
||||||
// 仅「单组浏览」(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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ================= 导出组用户 ================= */
|
/* ================= 导出组用户 ================= */
|
||||||
@@ -368,10 +348,6 @@ function onSortSaved() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.group-user-page .page-panel {
|
|
||||||
left: 348px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
|
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
|
||||||
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
|
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
|
||||||
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
|
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
|
||||||
|
|||||||
@@ -158,10 +158,10 @@ async function handlePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
|
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -35,15 +35,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import OapiLogDetailModal from './OapiLogDetailModal.vue';
|
import OapiLogDetailModal from './OapiLogDetailModal.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'OapiLogList' });
|
defineOptions({ name: 'OapiLogList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
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 }> = {
|
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||||
1: { text: '重要', className: 'text-error' },
|
1: { text: '重要', className: 'text-error' },
|
||||||
2: { text: '次要', className: 'text-warning' },
|
2: { text: '次要', className: 'text-warning' },
|
||||||
@@ -164,18 +160,10 @@ const columns = [
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||||
title: '操作',
|
actionColumn([
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||||
align: 'center',
|
|
||||||
fixed: 'right' as const,
|
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ async function handlePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -36,15 +36,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'OperationLogList' });
|
defineOptions({ name: 'OperationLogList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
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 }> = {
|
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||||
1: { text: '重要', className: 'text-error' },
|
1: { text: '重要', className: 'text-error' },
|
||||||
2: { text: '次要', className: 'text-warning' },
|
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) : '-'),
|
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作时间ed',
|
title: '操作时间',
|
||||||
key: 'createTime',
|
key: 'createTime',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||||
title: '操作',
|
actionColumn([
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||||
align: 'center',
|
|
||||||
fixed: 'right' as const,
|
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
|
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -37,15 +37,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Message } from 'view-ui-plus';
|
import { Message } from 'view-ui-plus';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
import PushLogDetailModal from './PushLogDetailModal.vue';
|
import PushLogDetailModal from './PushLogDetailModal.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'PushLog' });
|
defineOptions({ name: 'PushLog' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
const interfaceOptions = [
|
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 = [
|
const columns = [
|
||||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||||||
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
|
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
|
||||||
@@ -163,23 +159,11 @@ const columns = [
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
|
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
|
||||||
},
|
},
|
||||||
{
|
// 重试按行数据(成功与否)变化 → disabledWhen 置灰保位(§5.2 按钮位置稳定性红线),禁止 when 隐藏
|
||||||
title: '操作',
|
actionColumn([
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 150,
|
{ key: 'retry', label: '重试', on: (r: any) => handleRetry(r), danger: true, disabledWhen: (r: any) => r.success === 1 },
|
||||||
align: 'left',
|
], { mode: 'text', width: 150 }),
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
@@ -225,12 +209,4 @@ loadApps();
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: #19be6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-error {
|
|
||||||
color: #ed4014;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -169,10 +169,10 @@ function formatTime(val: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type AppModuleVo = {
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
children?: AppModuleVo[];
|
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 CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
|
||||||
type AuthzTreeNode = {
|
type AuthzTreeNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -118,12 +118,16 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
|||||||
}
|
}
|
||||||
function isAppChecked(app: AppItem): boolean {
|
function isAppChecked(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
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 而渲染 +/−(叶子也会出现展开图标)
|
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
|
||||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||||
@@ -132,7 +136,7 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
|||||||
id: `mod_${m.id}`,
|
id: `mod_${m.id}`,
|
||||||
name: String(m.name || ""),
|
name: String(m.name || ""),
|
||||||
open: true,
|
open: true,
|
||||||
checked: !!m.checked,
|
checked: isModuleAnyChecked(m),
|
||||||
nodeType: "module",
|
nodeType: "module",
|
||||||
rawId: Number(m.id),
|
rawId: Number(m.id),
|
||||||
appId,
|
appId,
|
||||||
@@ -255,7 +259,12 @@ function onTreeCheck(node: any) {
|
|||||||
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
|
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
|
||||||
const app = findLocalApp(node.appId);
|
const app = findLocalApp(node.appId);
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
syncAppModulesFromTree(app, node);
|
if ((app.modules || []).length === 0) {
|
||||||
|
// 无子模块应用:没有模块明细可同步,直接记整应用勾选态
|
||||||
|
app.checked = !!node.checked;
|
||||||
|
} else {
|
||||||
|
syncAppModulesFromTree(app, node);
|
||||||
|
}
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +274,14 @@ const saveNow = async () => {
|
|||||||
const moduleIds: number[] = [];
|
const moduleIds: number[] = [];
|
||||||
for (const cat of categoryList.value || []) {
|
for (const cat of categoryList.value || []) {
|
||||||
for (const app of cat.apps || []) {
|
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 {
|
try {
|
||||||
@@ -299,7 +315,7 @@ watch(
|
|||||||
.role-authz-tree {
|
.role-authz-tree {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -343,7 +359,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-name {
|
.role-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +457,13 @@ watch(
|
|||||||
text-align: center;
|
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) {
|
:deep(.ztree li a) {
|
||||||
max-width: calc(100% - 30px);
|
max-width: calc(100% - 30px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper role-authz-wrapper">
|
<div class="page-wrapper tree-wrapper role-authz-wrapper">
|
||||||
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
||||||
<div class="role-list-panel">
|
<div class="tree-container role-list-panel">
|
||||||
<div class="role-filter">
|
<div class="role-filter">
|
||||||
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
||||||
@on-change="onFilterChange">
|
@on-change="onFilterChange">
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
||||||
<div class="role-name">{{ role.name }}</div>
|
<div class="role-name">{{ role.name }}</div>
|
||||||
<div class="role-app">
|
<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>
|
<span v-else>{{ role.appName || "-" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
||||||
<div class="authz-panel">
|
<div class="main-container authz-panel">
|
||||||
<div v-if="selectedRole" class="authz-inner">
|
<div v-if="selectedRole" class="authz-inner">
|
||||||
<div class="authz-bar">
|
<div class="authz-bar">
|
||||||
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
||||||
<span class="authz-app">
|
<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 v-else>{{ selectedRole.appName || "-" }}</span>
|
||||||
</span>
|
</span>
|
||||||
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
||||||
@@ -152,19 +152,12 @@ function goManage() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.role-authz-wrapper {
|
/* 通道 tree-wrapper 骨架:外边界 16 / 两卡间隙 16 / 等高 stretch,高度对齐列表页 */
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
background: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list-panel {
|
.role-list-panel {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #ffffff;
|
|
||||||
border-right: 1px solid #ebedf0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,12 +189,12 @@ function goManage() {
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #b8c6ff;
|
border-color: var(--brand-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: #577afb;
|
border-color: var(--brand);
|
||||||
background: #f3f6ff;
|
background: var(--brand-soft);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,18 +228,21 @@ function goManage() {
|
|||||||
border-top: 1px solid #eeeeee;
|
border-top: 1px solid #eeeeee;
|
||||||
|
|
||||||
.ivu-btn-text {
|
.ivu-btn-text {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
flex: 1;
|
display: flex;
|
||||||
min-width: 0;
|
flex-direction: column;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
padding: 0 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-inner {
|
.authz-inner {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +251,18 @@ function goManage() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-title b {
|
.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 {
|
.authz-app {
|
||||||
@@ -268,9 +272,13 @@ function goManage() {
|
|||||||
|
|
||||||
.authz-tabs {
|
.authz-tabs {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-tab-content {
|
.authz-tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -158,10 +158,10 @@ function goAuthz() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #6c91f0;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,9 @@
|
|||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import Apis from "@/api";
|
import Apis from "@/api";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
roleId: number;
|
roleId: number;
|
||||||
@@ -44,10 +42,10 @@ const columns = [
|
|||||||
{ title: "账号", key: "account", width: 110 },
|
{ title: "账号", key: "account", width: 110 },
|
||||||
{ title: "姓名", key: "realName", width: 90 },
|
{ title: "姓名", key: "realName", width: 90 },
|
||||||
{ title: "部门", key: "groupPath", minWidth: 200 },
|
{ title: "部门", key: "groupPath", minWidth: 200 },
|
||||||
{
|
// UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
|
||||||
title: "操作", key: "action", width: 80, align: "center",
|
actionColumn([
|
||||||
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
|
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
|
||||||
},
|
], { mode: "text", width: 80 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
|||||||
Reference in New Issue
Block a user