feat(小组件管理改版): 卡片网格管理台 + 校级覆盖层 + 退役系统设置/应用管理
build / build (push) Successful in 42s

后端
- widget_catalog 加 3 列:scope_type(三态可见范围)/ role_types(可见角色)/ role_alias(角色专属命名 JSON)
- 新增两表:widget_catalog_school(点名表,uk_widget_school)、widget_school_setting(校级覆盖层,uk_widget_sch)
- WidgetController 新增 GetWidgetCatalogList / SaveWidgetCatalog / DeleteWidgetCatalog /
  GetWidgetSchoolSetting / SaveWidgetSchoolSetting;权限拆 checkSuperAdmin(仅超管)与
  checkSchAdmin(超管或 roleId∈{2,5,7})
- SaveWidgetSchoolSetting 强制 schId 取当前登录校,前端传参不采信(防越权)
- 显示名三级链:校级别名 > 角色专属命名 > 组件名;role_alias 坏 JSON 降级不抛
- 启动期幂等 seeding(空库才写 3 条内置目录行)
- 手工 DDL 脚本 resources/sql/widget_catalog_v2.sql(含回滚)

前端
- WidgetManage 重写为卡片网格:超管/校管双视角、类型页签 + 搜索、拖拽排序、启停、
  删除、登记内置组件、空态引导
- 新增 WidgetCard / WidgetEditModal / WidgetRegisterModal
- UserSettingApi 加 6 个方法;types 加 WidgetCatalog 接口
- 管理端列表整行回传(SaveWidgetCatalog 为整行 merge)

退役
- 删除 SysSetting.vue / SysTempManage.vue / AppSettingApi.ts 与后端 AppSettingController /
  AppCategory(SQL) 全链路,清理路由、Settings 导航与样式分支
- 保留 SysSettingApi.ts(壁纸/logo 池管理仍在使用)
This commit is contained in:
chenxing
2026-10-11 21:40:31 +08:00
parent 9980ea1264
commit 5a19a4ed79
13 changed files with 2559 additions and 1024 deletions
-63
View File
@@ -1,63 +0,0 @@
import Abstract from "@/utils/axios/abstract";
import { PageData, PageParams } from "@/types/Basics";
const path = "/api";
export interface AppCategoryItem {
id?: number;
name: string;
apiUrl: string;
dataKey: string;
paramsKey: string;
roleType: number;
cId?: number;
cDate?: string;
resDate?: string;
}
/** 系统设置-应用管理(应用类别 CRUD + 模板管理) */
class AppSettingApi extends Abstract {
/** 应用分页列表 */
getAppCategoryList(params: PageParams & { roleType: number; name?: string }) {
const url = path + "/GetAppCategoryList";
return this.getRequest<PageData>({ url, params });
}
/** 保存应用(新增/编辑) */
saveAppCategory(data: AppCategoryItem) {
const url = path + "/SaveAppCategory";
return this.postRequest({ url, data });
}
/** 删除应用 */
deleteAppCategory(id: number) {
const url = path + "/DelAppCategory";
return this.postRequest({ url, params: { id } });
}
/** 查询应用详情 */
findAppCategory(id: number) {
const url = path + "/FindAppCategory";
return this.getRequest<AppCategoryItem>({ url, params: { id } });
}
/** 刷新资源时间戳(上传模板后调用) */
updateResDate(id: number) {
const url = path + "/UpdateResDate";
return this.postRequest({ url, params: { id } });
}
/** 模板文件列表 */
getTempList(appModId: number) {
const url = path + "/GetTempList";
return this.getRequest<any[]>({ url, params: { appModId } });
}
/** 保存模板文件内容(JSON body,避免大文件内容超出 URL/请求头长度限制) */
saveTempFile(appModId: number, fileName: string, content: string) {
const url = path + "/SaveTempFile";
return this.postRequest({ url, data: { appModId, fileName, content } });
}
}
export default new AppSettingApi();
+28 -3
View File
@@ -1,4 +1,4 @@
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetPositionResp } from "@/types";
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetCatalog, WidgetPositionResp } from "@/types";
import { BaseDict, PageData, PageParams } from "@/types/Basics";
import Abstract from "@/utils/axios/abstract";
@@ -107,13 +107,38 @@ class UserSettings extends Abstract {
getWidgetCatalogList() {
const url = path + "/GetWidgetCatalogList";
return this.getRequest<any[]>({ url });
return this.getRequest<WidgetCatalog[]>({ url });
}
saveWidgetCatalog(catalog: Record<string, any>) {
/** 新增/编辑全局目录(仅超管):scopeType/roleTypes/roleAlias/scopeSchIds 一并提交 */
saveWidgetCatalog(catalog: Partial<WidgetCatalog> & Record<string, any>) {
const url = path + "/SaveWidgetCatalog";
return this.postRequest<any>({ url, data: catalog });
}
/** 删除全局目录(仅超管),后端连带删点名表与校级覆盖行 */
deleteWidgetCatalog(id: number) {
const url = path + "/DeleteWidgetCatalog";
return this.postRequest<any>({ url, params: { id } });
}
/** 读本校覆盖行(编辑回显):schId 由后端取当前登录校 */
getWidgetSchoolSetting(widgetId: number) {
const url = path + "/GetWidgetSchoolSetting";
return this.getRequest<any>({ url, params: { widgetId } });
}
/** 保存本校覆盖(校管/超管):schId 由后端强制取当前登录校,前端不传 */
saveWidgetSchoolSetting(setting: Record<string, any>) {
const url = path + "/SaveWidgetSchoolSetting";
return this.postRequest<any>({ url, data: setting });
}
/** 机构树(仅超管):递归裁剪到学校层,children 即学校列表,用于可见范围多选 */
getSchTree() {
const url = path + "/getSchTree";
return this.getRequest<any>({ url });
}
}
export default new UserSettings();
-5
View File
@@ -613,11 +613,6 @@ async function toggleWidgetShow() {
await UserSettingApi.toggleWidgetShow(id);
}
function linkSysSetting() {
isUserCardShow.value = false;
router.push("/syssetting");
}
function linkBackendManage() {
isUserCardShow.value = false;
const url = `${$conf.baseHost}/userbase/front/index.html`;
-14
View File
@@ -7,8 +7,6 @@ import LightApp from "@/views/LightApp.vue";
import Stats from "@/views/Stats.vue";
import StatsRelay from "@/views/StatsRelay/index.vue";
import TeacherCopilot from "@/views/TeacherCopilot.vue";
import SysSetting from "@/views/SysSetting.vue";
import SysTempManage from "@/views/SysTempManage.vue";
const routes: RouteRecordRaw[] = [
{
@@ -59,18 +57,6 @@ const routes: RouteRecordRaw[] = [
component: TeacherCopilot,
meta: { title: "教师助手" },
},
{
path: "/syssetting",
name: "SysSetting",
component: SysSetting,
meta: { title: "系统设置" },
},
{
path: "/syssetting/tempmanage/:id",
name: "SysTempManage",
component: SysTempManage,
meta: { title: "模板管理" },
},
{
path: "/msg",
name: "Msg",
+30
View File
@@ -51,6 +51,36 @@ export interface WidgetPositionResp extends WidgetPosition {
schId?: number;
}
/** 小组件候选池目录行(widget_catalog + 管理端视角附加字段) */
export interface WidgetCatalog {
id?: number;
/** 组件键:内置 = 前端 widgetRegistry 的键;自定义 = 自定义标识 */
code: string;
/** 显示名(校管视角已按「校级别名 > 角色命名 > 名称」合成) */
name: string;
/** 1 = 前端内置组件 2 = 自定义 HTML 片段 */
compType: number;
content?: string | null;
dataCode?: string | null;
/** 下发范围:0 = 全部学校 1 = 白名单 2 = 黑名单 */
scopeType?: number;
/** 点名的学校 id 列表(scopeType=1/2 时有效) */
scopeSchIds?: number[];
/** 可见角色:逗号分隔 RoleTypeEnum;NULL/空 = 全角色 */
roleTypes?: string | null;
/** 角色专属命名 JSON 文本:{"2":"全校课表"} */
roleAlias?: string | null;
/** 校管视角:本校别名 */
alias?: string | null;
/** 校管视角:超管下发的全局名称 */
originalName?: string | null;
/** 校管视角:本校是否已有覆盖行 */
covered?: boolean;
sort?: number;
enabled?: number;
updateTime?: string | null;
}
export interface BschTransferRoleInfo {
roleInfo: RoleInfo;
ticket: string;
+8 -34
View File
@@ -22,13 +22,8 @@
</template>
</aside>
<!-- 右区:sys pane 直接内嵌旧路由页 SysSetting(自带左右双卡视觉,故右区不再套白卡) -->
<div v-if="showSys" class="set-body set-body--sys">
<SysSetting />
</div>
<!-- 右卡:其余 pane -->
<div v-else class="set-body">
<!-- 右卡:内容区 -->
<div class="set-body">
<!-- 无权限:普通用户直达管理项 / 显式 ?pane=noperm -->
<div v-if="isDenied || curPane === 'noperm'" class="pane">
<div class="no-perm">
@@ -722,7 +717,6 @@ import { ref, reactive, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SysSetting from "@/views/SysSetting.vue";
import WidgetManage from "@/views/widget/WidgetManage.vue";
import BaseApi from "@/api/BaseApi";
import SysSettingApi from "@/api/SysSettingApi";
@@ -747,7 +741,7 @@ import { Message } from "view-ui-plus";
* profile 读 store.userInfo(手机/邮箱本人可改:UpdateContact 直写 UserBase,2026-10-02 任务B);
* password 走 updatePassword + 确认登出流程(照抄 UserInfoModal);
* avatar 移植 UserInfoModal 直传上传(「使用默认头像」保持占位,存储方案本轮不做);
* logo/wallpaper 池管理(列表 + 直传 + 删除)照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断);
* logo/wallpaper 池管理(列表 + 直传 + 删除)照抄旧系统设置页管理语义,LOGO 保存走 saveSchLogo(schId 后端按登录人推断);
* 壁纸「点选即生效」的 checkWallpaper 个人语义只保留在 my 层,默认层只维护可选池(不再代用户指定)。
*/
@@ -757,13 +751,13 @@ const store = useDefaultStore();
const { $conf } = useCurrentInstance();
const isAdmin = computed(() => !!store.getIsAdmin);
/** 超管判据与 SysSetting.vue 的 isSuperAdmin 同源(userInfo.type==1);徽标三态用 */
/** 超管判据 = userInfo.type==1;徽标三态用 */
const isSuper = computed(() => store.userInfo?.type == 1);
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错)。
* 三层化(S4)后「头像/主题/壁纸」所有身份都可用——每角色只编辑自己那一层,不再是管理项 */
const ADMIN_PANES = ["logo", "sidebar", "sys", "statset", "widget"];
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "sys", "statset", "widget", "noperm"];
const ADMIN_PANES = ["logo", "sidebar", "statset", "widget"];
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "statset", "widget", "noperm"];
/** 历史 ?pane= 键兼容(S4 重命名:mytheme/schooltheme→theme、mywallpaper→wallpaper),保证旧链接不 404/不白屏 */
const LEGACY_PANE_KEYS: Record<string, string> = {
@@ -793,8 +787,6 @@ watch(curPane, pane => {
});
const isDenied = computed(() => !isAdmin.value && ADMIN_PANES.includes(curPane.value));
/** sys pane 内嵌旧路由页 SysSetting(管理员态自带机构树+内容双卡) */
const showSys = computed(() => isAdmin.value && curPane.value === "sys");
function setPane(key: string) {
curPane.value = key;
@@ -823,7 +815,6 @@ const NAV_PORTAL: NavItem[] = [
{ key: "sidebar", name: "边栏设置", icon: svgWrap('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>') },
];
const NAV_SYSTEM: NavItem[] = [
{ key: "sys", name: "系统设置", icon: svgWrap('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>') },
{ key: "statset", name: "统计设置", icon: svgWrap('<path d="M18 20V10M12 20V4M6 20v-6"/>') },
{ key: "widget", name: "小组件管理", icon: svgWrap('<rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/>') },
];
@@ -984,7 +975,7 @@ function onAvatarFormatError() {
Message.error("仅支持 jpg/jpeg/png/gif/webp 格式图片!");
}
/* ===== logo / wallpaper:管理列表 + 直传 + 删除(照抄 SysSetting.vue 管理语义) ===== */
/* ===== logo / wallpaper:管理列表 + 直传 + 删除(沿用旧系统设置页管理语义) ===== */
const logoManageList = ref<any[]>([]);
const wallpaperManageList = ref<any[]>([]);
const logoSelUrl = ref<string>("");
@@ -2029,7 +2020,7 @@ watch(
<style scoped lang="scss">
/* 壳:透明,不平铺白(B 方案),gap 加在两卡的直接父容器上。
padding 与 .syssetting-wrap 对齐(15px 四周),overflow:hidden 与 .app-list-wrapper/.stats-wrap 对齐,
padding 与 .app-list-wrapper/.stats-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell,
避免内容撑出页面级滚动条把右侧留白撑大。 */
.set-shell {
display: flex;
@@ -2130,23 +2121,6 @@ watch(
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow-y: auto;
padding: 22px 26px 30px;
/* sys pane:右区不套白卡,由内嵌的 SysSetting 自带左右双卡 */
&.set-body--sys {
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: hidden;
display: flex;
flex-direction: column;
/* 内嵌的 SysSetting 自带 padding:15px,与 .set-shell 的 15px 叠加成 30px,此处抹平。
仅作用于本内嵌场景,独立页 /syssetting 不受影响(该页退役后可删本段) */
:deep(.syssetting-wrap) {
padding: 0;
}
}
}
.pane {
-331
View File
@@ -1,331 +0,0 @@
<template>
<div class="syssetting-wrap fix" v-if="isAdmin">
<!-- 应用管理(S4.7:原「基础设置」Tab 的 Logo/壁纸已迁到设置中心「门户外观」,此处只剩应用管理,
故去掉外层 Tabs 与只为基础设置服务的机构树/面包屑——应用管理接口不带 schId,跨校无需树) -->
<div class="right-box">
<div class="app-manage">
<!-- 角色分段用全局 .seg-tabs(与设置中心「桌面应用」同一套分段样式,方案 §10「应用管理自定义 Tab」) -->
<div class="role-tabs-row fix">
<div class="seg-tabs fl">
<div
v-for="item in roleTypeList"
:key="item.k"
:class="['sg', { active: roleTypeTab === item.k }]"
@click="onRoleTypeChange(item.k)"
>{{ item.v }}</div>
</div>
<Button class="fr" size="small" type="primary" @click="showCategory()">新增应用</Button>
</div>
<div class="search-wrap fix">
<Input
placeholder="输入关键字搜索"
class="fr"
search
suffix="ios-search"
style="width: 200px"
@on-search="onSearch"
v-model="dataParmas.name"
/>
</div>
<TablePage
:table-height="tableHeight"
:data-api="tableDataApi"
:columns="columns"
:page-params="pageParams"
:auto-load="true"
ref="refTablePage"
/>
<!-- 新增/编辑应用 -->
<Modal
v-model="isModal"
:mask-closable="false"
width="600"
:title="currentId == null ? '新增应用' : '编辑应用'"
>
<Form ref="refFormData" class="mt-8 mr-24" :model="formData" :rules="ruleValidate" :label-width="120">
<FormItem label="应用名称:" prop="name">
<Input placeholder="请输入应用名称" v-model="formData.name" />
</FormItem>
<FormItem label="接口地址:" prop="apiUrl">
<Input placeholder="请输入接口地址" v-model="formData.apiUrl" />
</FormItem>
<FormItem label="接口参数:" prop="paramsKey">
<ul class="parmas-ul">
<li class="fix mb-12" v-for="(item, index) in parmasList" :key="index">
<span class="ivu-form-item-label">参数名称</span>
<Input class="fl mr-12" v-model="item.k" style="width: 80px" />
<template v-if="item.type == 1">
<span class="ivu-form-item-label">参数类型</span>
<span>userId</span>
</template>
<template v-else-if="item.type == 2">
<span class="ivu-form-item-label">参数类型</span>
<span>学校Id</span>
</template>
<template v-else>
<span class="ivu-form-item-label">参数定值</span>
<Input class="fl mr-12" style="width: 80px" v-model="item.v" />
</template>
<Button
v-if="item.type != 1"
type="error"
ghost
icon="md-remove"
class="ml-12"
@click="parmasList.splice(index, 1)"
></Button>
</li>
</ul>
<div class="fix">
<Button class="fl" @click="parmasList.push({ k: '', v: '', type: 3 })">添加固定参数</Button>
<Button
v-if="!!currentId"
class="ml-8"
@click="parmasList.push({ k: 'schId', v: '', type: 2 })"
>
添加SchId参数
</Button>
</div>
</FormItem>
<FormItem label="数据解析:" prop="dataKey">
<Input placeholder="请输入数据字段" v-model="formData.dataKey" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" :loading="isSubmitLoading" @click="onEditSave">提交</Button>
<Button @click="onReset">取消</Button>
</template>
</Modal>
</div>
</div>
</div>
<div class="no-permission" v-else>无权限访问</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import TablePage from "@/components/TablePage.vue";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import { OperatorEnum } from "@/types";
import { PageParams } from "@/types/Basics";
import AppSettingApi, { AppCategoryItem } from "@/api/AppSettingApi";
import mittBus from "@/utils/mitt";
const router = useRouter();
const store = useDefaultStore();
const isAdmin = computed(() => store.getIsAdmin);
// ==================== 应用管理(AppCategory) ====================
const roleTypeList = [
{ k: 1, v: "校管理员" },
{ k: 2, v: "教师" },
{ k: 3, v: "学生" },
];
const roleTypeTab = ref<number>(1);
const dataParmas = reactive({ name: "" });
const pageParams: PageParams = reactive({ page: 1, count: 10 });
const refTablePage = ref<any>(null);
const tableHeight = Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true });
const tableDataApi = (params: PageParams) =>
AppSettingApi.getAppCategoryList({
...params,
roleType: roleTypeTab.value,
name: dataParmas.name || undefined,
});
const columns = ref<any[]>([
{ type: "index", title: "序号", width: 70, align: "center" },
{ title: "应用名称", width: 200, align: "left", key: "name" },
{
title: "数据类别",
width: 100,
align: "left",
render: (h: any, params: any) => {
let find = roleTypeList.find(x => x.k == params.row.roleType);
return h("span", !find ? "未知" : find.v);
},
},
{ title: "接口地址", align: "left", key: "apiUrl", tooltip: true },
{ title: "数据解析", align: "center", key: "dataKey" },
{
title: "操作",
width: 180,
align: "center",
render: (h: any, params: any) => {
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => showCategory(params.row.id)),
Utils.operatingTextHandle(h, "模板管理", "mr-2", () => {
let { id, name } = params.row;
router.push({ name: "SysTempManage", params: { id }, query: { name } });
}),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteCategory(params.row.id)),
];
},
},
]);
const isSubmitLoading = ref(false);
const currentId = ref<number | null>(null);
const parmasList = ref<any[]>([]);
const isModal = ref(false);
const formData = reactive<AppCategoryItem>({ id: null as any, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
const ruleValidate = {
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
};
watch(
parmasList,
val => {
formData.paramsKey = JSON.stringify(val);
},
{ deep: true }
);
/** 分段 Tab 点击(原 iView @on-click 改为自绘分段,值即 roleType) */
function onRoleTypeChange(k: number) {
if (roleTypeTab.value === k) return;
roleTypeTab.value = k;
refTablePage.value?.reloadData();
}
function onSearch() {
refTablePage.value?.reloadData();
}
function showCategory(id?: number) {
if (typeof id === "undefined") {
currentId.value = null;
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
parmasList.value = [
{ k: "userId", v: "", type: 1 },
{ k: "schId", v: "", type: 2 },
];
} else {
const row = refTablePage.value.list.find((x: any) => x.id == id);
if (!row) return;
currentId.value = id;
Object.assign(formData, { id: row.id, name: row.name, apiUrl: row.apiUrl, dataKey: row.dataKey });
try {
parmasList.value = JSON.parse(row.paramsKey || "[]");
} catch (e) {
parmasList.value = [];
}
}
isModal.value = true;
}
async function onEditSave() {
if (!formData.name) {
Message.info("请填写应用名称");
return;
}
isSubmitLoading.value = true;
try {
const item: AppCategoryItem = { ...formData, roleType: roleTypeTab.value };
await AppSettingApi.saveAppCategory(item);
Message.success(`${currentId.value == null ? "添加" : "操作"}成功!`);
refTablePage.value.reloadData(currentId.value == null ? OperatorEnum.add : OperatorEnum.edit);
isModal.value = false;
isSubmitLoading.value = false;
currentId.value = null;
} catch (error) {
console.error(error);
isSubmitLoading.value = false;
}
}
function onReset() {
currentId.value = null;
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
parmasList.value = [];
isModal.value = false;
}
async function onDeleteCategory(id: number) {
try {
await AppSettingApi.deleteAppCategory(id);
Message.success("操作成功!");
refTablePage.value.reloadData(OperatorEnum.del);
} catch (error) {
console.error(error);
}
}
// ==================== 生命周期 ====================
/** 切换身份/角色后重取应用列表(首屏由 TablePage 的 auto-load 负责) */
function onSwitchRole() {
if (!isAdmin.value) return;
refTablePage.value?.reloadData();
}
onMounted(() => {
if (!isAdmin.value) return;
mittBus.on("switchRole", onSwitchRole);
});
onUnmounted(() => {
mittBus.off("switchRole", onSwitchRole);
});
</script>
<style lang="scss" scoped>
.syssetting-wrap {
height: 100%;
padding: 15px;
.right-box {
height: 100%;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
padding: 15px;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.no-permission {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
/* 应用管理:.right-box 是纵向 flex,此处占满剩余高度(原外层 Tabs 的 44px 头部已随「基础设置」Tab 一起移除) */
.app-manage {
flex: 1;
min-height: 0;
overflow: hidden;
.role-tabs-row {
margin-bottom: 12px;
.fr {
margin-top: 3px;
}
}
.search-wrap {
margin-bottom: 10px;
}
.parmas-ul {
.ivu-form-item-label {
float: left;
margin-right: 8px;
line-height: 32px;
}
}
}
</style>
-235
View File
@@ -1,235 +0,0 @@
<template>
<div class="tempmanage-wrap fix">
<div class="file-box fl">
<h4 class="file-title">{{ tempName }}</h4>
<ul class="file-list">
<li
v-for="item in fileList"
:key="item.name"
:class="{ active: currentFileName == item.name }"
@click="onSelectFile(item)"
>
<SvgIcon name="menu-2" size="14" color="#5979ea" style="margin-right: 6px" />
{{ item.name }}
</li>
<li v-if="fileList.length == 0" class="empty">暂无文件,请上传 zip/rar</li>
</ul>
</div>
<div class="main-box">
<div class="fix mb-12">
<Upload
:on-success="onUploadSuccess"
:show-upload-list="false"
:action="tempUploadAction"
:accept="fileAccept"
:data="uploadParams"
:with-credentials="true"
:headers="uploadHeaders"
class="fl"
>
<Button class="fl mr-12" type="primary" size="large">上传应用</Button>
</Upload>
<Button class="fl mr-12" type="primary" size="large" @click="downTemp">下载应用</Button>
<h4 class="title fl tc">{{ tempName }}模板空间</h4>
</div>
<div class="editor-box">
<Codemirror
v-model="currentContent"
:extensions="extensions"
:style="{ height: '100%' }"
:indent-with-tab="true"
placeholder="请选择左侧文件进行编辑"
/>
<div class="editor-footer fix">
<Button type="primary" :disabled="!currentFileName" :loading="isSaving" @click="saveTemp">
保存更改
</Button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { Codemirror } from "vue-codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { xml } from "@codemirror/lang-xml";
import { json } from "@codemirror/lang-json";
import { EditorView } from "@codemirror/view";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
import AppSettingApi from "@/api/AppSettingApi";
const route = useRoute();
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const appId = computed(() => (route.params.id as string) || "");
const tempName = computed(() => (route.query.name as string) || "");
const fileList = ref<any[]>([]);
const currentFileName = ref<string | null>(null);
const currentContent = ref<string>("");
const isSaving = ref(false);
const fileAccept = ".rar,.zip";
const tempUploadAction = computed(() => $conf.serviceHost + "/api/tempUpload");
const uploadParams = computed(() => ({ appModId: appId.value }));
const extensions = computed(() => {
if (!currentFileName.value) return [EditorView.lineWrapping];
const suffix = currentFileName.value.substring(currentFileName.value.lastIndexOf(".") + 1).toLowerCase();
let lang: any[] = [];
if (["js", "ts", "jsx", "tsx"].includes(suffix)) lang = [javascript()];
else if (suffix == "json") lang = [json()];
else if (["css", "less", "scss"].includes(suffix)) lang = [css()];
else if (suffix == "html" || suffix == "htm") lang = [html()];
else if (suffix == "xml") lang = [xml()];
return [...lang, EditorView.lineWrapping];
});
async function getFileList() {
const list = await AppSettingApi.getTempList(Number(appId.value));
fileList.value = list || [];
}
async function onSelectFile(item: any) {
currentFileName.value = item.name;
await loadContent(item.path);
}
/** 读取文件内容:静态资源跨域读取,携带 yschapirequest 头触发后端 CORS 过滤器 */
async function loadContent(path: string) {
try {
const resp = await fetch(path, {
headers: { yschapirequest: "1" },
credentials: "include",
});
if (!resp.ok) {
Message.error("读取文件失败");
return;
}
currentContent.value = await resp.text();
} catch (e) {
console.error(e);
Message.error("读取文件失败");
}
}
function onUploadSuccess() {
Message.success("上传成功!");
AppSettingApi.updateResDate(Number(appId.value));
getFileList();
}
function downTemp() {
window.location.href =
$conf.serviceHost + `/api/downloadTemp?appModId=${appId.value}&name=${encodeURIComponent(tempName.value)}`;
}
async function saveTemp() {
if (!currentFileName.value) return;
isSaving.value = true;
try {
await AppSettingApi.saveTempFile(Number(appId.value), currentFileName.value, currentContent.value);
Message.success("保存成功!");
} catch (e) {
console.error(e);
} finally {
isSaving.value = false;
}
}
onMounted(() => {
getFileList();
});
</script>
<style lang="scss" scoped>
.tempmanage-wrap {
height: 100%;
padding: 15px;
.file-box {
width: 260px;
height: 100%;
background: #fff;
border-radius: 5px;
padding: 15px;
overflow-y: auto;
box-sizing: border-box;
.file-title {
margin-bottom: 10px;
color: #333;
font-size: 14px;
}
.file-list {
li {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: #f5f7fa;
}
&.active {
background: #eaf1ff;
color: #5979ea;
}
&.empty {
color: #999;
cursor: default;
}
}
}
}
.main-box {
margin-left: 275px;
height: 100%;
background: #fff;
border-radius: 5px;
padding: 15px;
box-sizing: border-box;
.title {
margin-left: 20px;
line-height: 40px;
}
.editor-box {
height: calc(100% - 52px);
border: 1px solid #e8eaec;
border-radius: 5px;
overflow: hidden;
:deep(.cm-editor) {
height: calc(100% - 48px);
}
.editor-footer {
height: 48px;
padding: 8px 12px;
border-top: 1px solid #e8eaec;
background: #fafafa;
}
}
}
}
</style>
+1 -1
View File
@@ -429,7 +429,7 @@ function fmtTime(sendTime?: string) {
<style scoped lang="scss">
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
padding 与 .set-shell 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
.msg-shell {
display: flex;
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
+403
View File
@@ -0,0 +1,403 @@
<template>
<div
class="wcard"
:class="{ off: !item.enabled, dragging, 'drag-over': dragOver }"
:draggable="draggable"
@mouseup="draggable = false"
@dragstart="onDragStart"
@dragover.prevent="onDragOver"
@drop.prevent="onDrop"
@dragend="onDragEnd"
>
<div class="wc-top">
<span class="badge" :class="item.compType === 1 ? 'builtin' : 'custom'">{{ typeLabel }}</span>
<template v-if="item.compType === 1">
<span v-if="registryHit" class="badge ok" title="前端 widgetRegistry 已收录该键">注册表 ✓</span>
<span v-else class="badge warn" title="前端注册表未收录,用户侧会落「暂不可用」空态">⚠ 未收录</span>
</template>
<span v-else class="badge mute" title="片段正文存在 widget_catalog.content">内容存库</span>
<span class="drag" title="按住拖拽排序" @mousedown.stop="draggable = true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor">
<circle cx="9" cy="5.5" r="1.6" />
<circle cx="15" cy="5.5" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18.5" r="1.6" />
<circle cx="15" cy="18.5" r="1.6" />
</svg>
</span>
</div>
<div class="wc-name">
<span>{{ item.name }}</span>
<span v-if="item.alias" class="badge alias" :title="`校级别名;超管下发名称:${item.originalName || item.name}`">别名</span>
<span v-if="adaptive" class="tag-adaptive">按角色适配</span>
</div>
<div class="wc-code">{{ item.code }}</div>
<div class="wc-meta">
<div class="row">
<span class="k">取数键</span>
<span class="v">
<code v-if="item.dataCode">{{ item.dataCode }}</code>
<span v-else class="dim">—</span>
</span>
</div>
<div class="row">
<span class="k">可见</span>
<span class="v">
<span class="rchip" :class="scopeCls">{{ scopeText }}</span>
<span v-if="roleAll" class="rchip all">全角色</span>
<template v-else>
<span v-for="r in roleList" :key="r" class="rchip">{{ roleNameOf(r) }}</span>
</template>
</span>
</div>
<div class="row">
<span class="k">更新</span>
<span class="v dim">{{ item.updateTime || "—" }}</span>
</div>
</div>
<div class="wc-ft">
<span class="wc-ops">
<a class="op" @click="$emit('edit', item)">{{ isSuperView ? "编辑" : "设置" }}</a>
<a class="op" @click="$emit('preview', item)">预览</a>
<a v-if="isSuperView" class="op danger" @click="$emit('del', item)">删除</a>
</span>
<span class="wc-sw">
<span class="sw" :class="{ 'is-on': !!item.enabled }" @click="$emit('toggle', item)"></span>
<span class="wc-sw-t" :class="{ on: !!item.enabled }">{{ item.enabled ? "已启用" : "已下线" }}</span>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import type { WidgetCatalog } from "@/types";
import { RoleTypeEnum } from "@/types";
const props = defineProps<{
item: WidgetCatalog;
isSuperView: boolean;
registryHit: boolean;
schools: { id: number; name: string }[];
dragging: boolean;
dragOver: boolean;
}>();
const emit = defineEmits<{
(e: "edit" | "preview" | "toggle" | "del", item: WidgetCatalog): void;
(e: "dragstart" | "dragover" | "drop", id: number | undefined): void;
(e: "dragend"): void;
}>();
const draggable = ref(false);
const typeLabel = computed(() => (props.item.compType === 1 ? "内置组件" : "自定义片段"));
const adaptive = computed(() => props.item.compType === 1 && props.item.code === "syllabus");
const roleKeys = computed(() => {
const raw = props.item.roleTypes;
if (!raw) return [] as number[];
return raw
.split(",")
.map(x => Number(x.trim()))
.filter(x => !isNaN(x))
.sort();
});
const roleAll = computed(() => roleKeys.value.length === 0 || roleKeys.value.length === 3);
const roleList = computed(() => (roleAll.value ? [] : roleKeys.value));
const roleNameOf = (k: number) => ({ [RoleTypeEnum.Teacher]: "教师", [RoleTypeEnum.SchAdmin]: "校领导", [RoleTypeEnum.Student]: "学生" })[k] || `角色 ${k}`;
const schoolNameOf = (id: number) => props.schools.find(x => x.id === id)?.name || `学校 ${id}`;
const scopeCls = computed(() => (props.item.scopeType === 1 ? "sch" : props.item.scopeType === 2 ? "no" : "all"));
const scopeText = computed(() => {
const names = (props.item.scopeSchIds || []).map(schoolNameOf).join("、");
if (props.item.scopeType === 1) return `指定可见 ${names}`;
if (props.item.scopeType === 2) return `指定不可见 ${names}`;
return "全部学校";
});
function onDragStart(e: DragEvent) {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(props.item.id));
}
emit("dragstart", props.item.id);
}
function onDragOver() {
if (draggable.value) emit("dragover", props.item.id);
}
function onDrop() {
emit("drop", props.item.id);
draggable.value = false;
}
function onDragEnd() {
draggable.value = false;
emit("dragend");
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
$wm-brand-line: color.mix(#ffffff, $wm-brand, 78%);
.wcard {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-line2: #e5e8ee;
--wm-ok: #12a150;
--wm-ok-bg: #e8f7ee;
--wm-warn: #b54708;
--wm-warn-bg: #fff4e5;
--wm-danger: #d92d20;
--wm-danger-bg: #fdecea;
border: 1px solid var(--wm-line2);
border-radius: 10px;
padding: 13px 14px 0;
background: #fff;
transition: border-color 0.16s, box-shadow 0.16s, transform 0.16s;
position: relative;
// 同一行卡片等高、底部操作行对齐(网格 stretch + 底部自适应)
display: flex;
flex-direction: column;
&:hover {
border-color: $wm-brand-line;
box-shadow: 0 6px 18px rgba($wm-brand, 0.12);
transform: translateY(-1px);
}
&.off {
background: #fcfcfd;
.wc-name {
color: var(--wm-t3);
}
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
border-color: $wm-brand;
box-shadow: 0 0 0 2px rgba($wm-brand, 0.22);
}
}
.badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: $wm-brand-weak;
color: $wm-brand;
}
&.custom {
background: var(--wm-warn-bg);
color: var(--wm-warn);
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
&.warn {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
&.mute {
background: #f0f1f3;
color: var(--wm-t3);
}
&.alias {
background: #eaf6ff;
color: #1677b3;
}
}
.wc-top {
display: flex;
align-items: center;
gap: 5px;
min-height: 22px;
}
.drag {
margin-left: auto;
width: 22px;
height: 22px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
color: #c4c9d2;
transition: all 0.15s;
flex-shrink: 0;
&:hover {
color: $wm-brand;
background: $wm-brand-weak;
}
&:active {
cursor: grabbing;
}
}
.wc-name {
font-size: 14px;
font-weight: 600;
color: var(--wm-t1);
margin-top: 9px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tag-adaptive {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
background: #eaf6ff;
color: #1677b3;
white-space: nowrap;
}
.wc-code {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
margin-top: 2px;
}
.wc-meta {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--wm-line2);
display: flex;
flex-direction: column;
gap: 7px;
.row {
display: flex;
gap: 8px;
font-size: 12px;
line-height: 1.7;
.k {
width: 46px;
flex-shrink: 0;
color: var(--wm-t4);
}
.v {
color: var(--wm-t2);
min-width: 0;
}
.dim {
color: var(--wm-t4);
}
}
}
.rchip {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 7px;
border-radius: 5px;
font-size: 11px;
background: #eef1f6;
color: #5b6472;
margin-right: 4px;
margin-bottom: 2px;
white-space: nowrap;
&.all {
background: #f0f1f3;
color: var(--wm-t3);
}
&.sch {
background: #eaf6ff;
color: #1677b3;
}
&.no {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
}
.wc-ft {
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
padding: 9px 0 11px;
border-top: 1px solid var(--wm-line);
}
.wc-ops {
display: flex;
gap: 11px;
min-width: 0;
}
.op {
font-size: 12.5px;
color: $wm-brand;
cursor: pointer;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
&.danger {
color: var(--wm-danger);
}
}
.wc-sw {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
.sw {
position: relative;
display: inline-block;
width: 38px;
height: 21px;
border-radius: 11px;
background: #d5d8de;
cursor: pointer;
transition: background 0.18s;
vertical-align: middle;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 17px;
height: 17px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left 0.18s;
}
&.is-on {
background: $wm-brand;
&::after {
left: 19px;
}
}
}
.wc-sw-t {
font-size: 12px;
color: var(--wm-t4);
&.on {
color: var(--wm-ok);
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
<template>
<Teleport to="body">
<div v-if="visible" class="wm-reg-root">
<div class="modal-mask show" @click="close"></div>
<div class="modal show sm">
<div class="m-head">
<div>
<h4>登记内置组件 <span class="badge builtin">超管专属</span></h4>
<div class="m-sub">键须与 front/src/components/widgets/index.ts 一致</div>
</div>
<button class="x" @click="close">×</button>
</div>
<div class="m-body">
<p>
<b>原理:</b>内置组件的<b>代码</b>打在前端工程里,并在注册表 <code>index.ts</code> 注册成一个<b>键</b>;
<code>widget_catalog</code> 只存「目录行」(键 + 展示名 + 排序 + 启用 + 范围 + 角色 + 别名覆盖),<b>不存内容</b>。
用户端拿到候选池后按 <code>code</code> 去注册表查,命中就渲染对应 Vue 组件。<br />
所以「登记」= 往目录表插一条 <code>comp_type=1, code=注册表里的键</code> 的记录,<b>不会让代码里凭空多出一个组件</b>。
</p>
<ol>
<li>前端新建 <code>components/widgets/XxxWidget.vue</code></li>
<li>在 <code>components/widgets/index.ts</code> 注册 <code>{ code, name }</code>(键一旦发布就别改,改键 = 换身份)</li>
<li>需要动态数据时,后端 <code>WidgetDataService</code> 加白名单分支</li>
<li><b>发版部署</b>(内置组件必须发版才生效)</li>
<li>回到本弹窗点「登记」,或跑 <code>resources/sql/widget_catalog.sql</code> 种子</li>
</ol>
<ul class="reg-list">
<li v-for="row in rows" :key="row.code">
<input v-model="row.name" class="inp name-inp" maxlength="30" placeholder="展示名" />
<span class="rc">{{ row.code }}</span>
<span class="grow"></span>
<span v-if="row.registered" class="badge ok">已登记</span>
<button v-else class="btn sm primary" :disabled="saving" @click="registerOne(row)">登记</button>
</li>
<li v-if="rows.length === 0" class="empty">前端注册表里没有可登记的键</li>
</ul>
</div>
<div class="m-foot">
<span class="save-tip">{{ tip }}</span>
<button class="btn plain" @click="close">关闭</button>
<button v-if="pendingCount > 1" class="btn primary" :disabled="saving" @click="registerAll">
一键登记 {{ pendingCount }} 个未登记键
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import UserSettingApi from "@/api/UserSettingApi";
const props = defineProps<{
visible: boolean;
/** 前端注册表的键(来自 components/widgets/index.ts) */
registryCodes: string[];
/** 已登记进候选池的内置键 */
registeredCodes: string[];
/** 登记时的起始排序基数 */
nextSort: number;
}>();
const emit = defineEmits<{ (e: "close"): void; (e: "saved"): void }>();
const saving = ref(false);
const tip = ref("");
const rows = reactive<{ code: string; name: string; registered: boolean }[]>([]);
const pendingCount = computed(() => rows.filter(x => !x.registered).length);
watch(
() => props.visible,
show => {
if (!show) return;
rows.splice(0, rows.length);
props.registryCodes.forEach(code => {
rows.push({ code, name: code, registered: props.registeredCodes.includes(code) });
});
tip.value = "";
},
{ immediate: true }
);
function close() {
emit("close");
}
async function registerOne(row: { code: string; name: string }) {
await registerBatch([{ code: row.code, name: row.name }]);
}
async function registerAll() {
const payload = rows.filter(x => !x.registered).map(x => ({ code: x.code, name: x.name }));
await registerBatch(payload);
}
async function registerBatch(payload: { code: string; name: string }[]) {
if (payload.length === 0) return;
saving.value = true;
let done = 0;
let baseSort = props.nextSort;
for (const one of payload) {
try {
await UserSettingApi.saveWidgetCatalog({
id: null,
code: one.code,
name: (one.name || one.code).trim(),
compType: 1,
content: null,
dataCode: one.code,
scopeType: 0,
scopeSchIds: [],
roleTypes: null,
roleAlias: null,
sort: baseSort,
enabled: 1,
});
done++;
baseSort += 10;
} catch (e) {
console.warn("[widget] 登记失败", one.code, e);
Message.error(typeof e === "string" ? e : `登记失败:${one.code}`);
}
}
saving.value = false;
if (done > 0) {
Message.success(`已登记 ${done} 个内置组件`);
tip.value = "登记后进入候选池,可回主界面继续配范围 / 角色";
emit("saved");
}
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary;
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
.wm-reg-root {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-ok-bg: #e8f7ee;
--wm-ok: #12a150;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(16, 24, 40, 0.3);
z-index: 110;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 780px;
max-width: 94vw;
max-height: 88vh;
background: #fff;
border-radius: 12px;
z-index: 111;
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
&.sm {
width: 560px;
}
}
.m-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wm-line);
h4 {
font-size: 15px;
font-weight: 600;
color: var(--wm-t1);
display: flex;
align-items: center;
gap: 8px;
}
.m-sub {
font-size: 12px;
color: var(--wm-t4);
margin-top: 4px;
font-family: Consolas, Menlo, monospace;
}
.x {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
color: var(--wm-t4);
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
&:hover {
background: #f2f4f8;
color: var(--wm-t2);
}
}
}
.m-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 22px 18px;
> p {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.75;
margin-bottom: 12px;
}
ol {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.9;
padding-left: 20px;
margin-bottom: 12px;
b {
color: var(--wm-t2);
}
}
}
.m-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 22px 16px;
border-top: 1px solid var(--wm-line);
.save-tip {
flex: 1;
font-size: 12px;
color: var(--wm-ok);
}
}
.badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: $wm-brand-weak;
color: $wm-brand;
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
}
.reg-list {
list-style: none;
border: 1px solid var(--wm-line);
border-radius: 8px;
overflow: hidden;
li {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border-bottom: 1px solid var(--wm-line);
font-size: 13px;
&:last-child {
border-bottom: none;
}
&.empty {
color: var(--wm-t4);
font-size: 12.5px;
}
.grow {
flex: 1;
}
.rc {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
}
}
}
.inp {
height: 30px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 0 10px;
font-size: 12.5px;
outline: none;
background: #fff;
&:focus {
border-color: $wm-brand;
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
}
&.name-inp {
width: 150px;
flex-shrink: 0;
}
}
.btn {
border: 1px solid #d0d5dd;
background: #fff;
color: #344054;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12.5px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
&:hover {
border-color: var(--wm-t4);
}
&.primary {
background: $wm-brand;
border-color: $wm-brand;
color: #fff;
}
&.sm {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
&[disabled] {
opacity: 0.5;
cursor: default;
}
}
</style>