feat(小组件内置化): 候选池真源换自建 widget_catalog + 统一取数/内容/管理端四接口
build / build-push (push) Successful in 58s

- widget_catalog:comp_type=1 内容在 front 工程(库里只登记键)、=2 内容存 content,全表无 url 字段 ⇒ 小组件链路彻底不再有「html 地址」;sch_id 只留字段与 visibleToSchool 过滤钩子(v1 全校一致)
- 新接口(真实路径按 /{platform} 展开为 /api/xxx):getWidgetData?code=&params=(统一取数,未知 code 回空数据+msg 不抛 500)、getWidgetContent?code=(仅 comp_type=2 按需取正文,不塞进 getUserSettings 免每次进桌面下发全部 HTML)、GetWidgetCatalogList、SaveWidgetCatalog(content 走 JSON body)
- 管理端鉴权照抄 AppSettingController.checkAdmin(超管 type==1 或 roleId∈{2,5,7},判定标准一字未改;该方法是 private 故复制逻辑),拒绝走 BizException(ErrorCode.FORBIDDEN),按平台信封为 code=3 非 500
- getUserSettings:widgetAppList 改读自建表并增 code/compType、删 url,id/appId 同值以复用既有 widgetAppIds 存取与面板排序链路;saveUserWidget/toggleWidgetShow 未动
- 数据源按拍板走 L2 可溯源 mock(yunhai 9 工程检索课表/听评课零命中),字段逐一等于 design/widget-snapshots-old-bsch 旧接口口径,接入点以 // TODO 接入 oapi: 标注;本期无出站,故未注入 YschOpenCall(零手写签名代码)
- DDL:仓库无 migration 机制(ddl-auto=update),脚本按既有约定落 resources/sql/widget_catalog.sql,dev 自动建表、测试/生产需手工执行(INSERT IGNORE 可重复跑)
This commit is contained in:
chenxing
2026-10-11 08:47:54 +08:00
parent 47f9e3f751
commit 29d32eed6e
8 changed files with 506 additions and 13 deletions
@@ -19,6 +19,7 @@ import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IUserSettingsService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.UsedAppService;
import com.yunhai.userhome.utils.EnumUtil;
import com.yunhai.userhome.utils.FileUtil;
@@ -34,11 +35,6 @@ import java.util.stream.Collectors;
/**
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
*
* <p>待确认接口(已写入待确认清单):
* <ul>
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
* </ul>
*/
@RestController
@RequestMapping(Config.API_PATH)
@@ -68,6 +64,9 @@ public class UserSettingsController {
@Autowired
private AppIconKeyService appIconKeyService;
@Autowired
private IWidgetCatalogService widgetCatalogService;
// ==================== 壁纸 ====================
/**
@@ -288,9 +287,8 @@ public class UserSettingsController {
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
UserSettings model = new UserSettings();
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId);
List<BoaApp> appPageList = Collections.emptyList();
// 候选池真源 = 自建 widget_catalog(enabled=1,sch_id 按校过滤;NULL=全校)
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId);
if (optional.isPresent()) model = optional.get();
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
@@ -303,12 +301,14 @@ public class UserSettingsController {
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
}
List<Map> mapList = appPageList.stream().map(boaApp -> {
// id/appId 同值:对齐既有 widgetAppIds 的存取与面板排序链路;不再有 url
List<Map> mapList = catalogList.stream().map(catalog -> {
Map map = new HashMap<>();
map.put("id", boaApp.getId());
map.put("appId", boaApp.getId());
map.put("name", boaApp.getName());
map.put("url", boaApp.getWebAddr());
map.put("id", catalog.getId());
map.put("appId", catalog.getId());
map.put("name", catalog.getName());
map.put("code", catalog.getCode());
map.put("compType", catalog.getCompType());
return map;
}).collect(Collectors.toList());
@@ -0,0 +1,120 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.WidgetDataService;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
*
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
*/
@RestController
@RequestMapping(Config.API_PATH)
public class WidgetController {
private static final int COMP_TYPE_FRAGMENT = 2;
@Autowired
private IWidgetCatalogService widgetCatalogService;
@Autowired
private WidgetDataService widgetDataService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
/** 统一取数入口:params 为 urlencoded 查询串;未配置的数据键回空数据,不抛异常 */
@GetMapping(value = "getWidgetData", produces = "application/json")
public Object getWidgetData(BoaUser authUserWithDept,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "params", required = false) String params) {
Object data = widgetDataService.getData(code, params, currentSchId(authUserWithDept));
if (data != null) {
return data;
}
Map<String, Object> empty = new HashMap<>();
empty.put("msg", "该小工具暂无可用数据源:" + code);
return empty;
}
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
@GetMapping(value = "getWidgetContent", produces = "application/json")
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept,
@RequestParam(value = "code", required = false) String code) {
Map<String, Object> result = new HashMap<>();
result.put("content", "");
Optional<WidgetCatalog> optional = widgetCatalogService.findByCode(code);
if (!optional.isPresent()) {
result.put("msg", "小工具不存在:" + code);
return result;
}
WidgetCatalog catalog = optional.get();
if (!widgetCatalogService.visibleToSchool(catalog, currentSchId(authUserWithDept))) {
result.put("msg", "该小工具未对本校下发");
return result;
}
if (catalog.getCompType() == null || catalog.getCompType() != COMP_TYPE_FRAGMENT) {
result.put("msg", "内置组件的内容在 front 工程里,无按需取内容这条路:" + code);
return result;
}
result.put("code", catalog.getCode());
result.put("name", catalog.getName());
result.put("compType", catalog.getCompType());
result.put("dataCode", catalog.getDataCode());
result.put("content", catalog.getContent() == null ? "" : catalog.getContent());
return result;
}
@GetMapping(value = "GetWidgetCatalogList", produces = "application/json")
public List<WidgetCatalog> getWidgetCatalogList(BoaUser authUserWithDept) {
checkAdmin(authUserWithDept);
return widgetCatalogService.findAllForAdmin();
}
/** 管理端保存:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
checkAdmin(authUserWithDept);
widgetCatalogService.save(widgetCatalog);
}
private Integer currentSchId(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
return currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
}
/**
* 写操作权限校验:超管(type==1) 或 校管理员(roleId∈{2,5,7}),其余拒绝——判定标准与
* AppSettingController.checkAdmin 一字不改(该方法是 private,不能直接调用)。
* 注:平台 yh-api-std 的异常处理器无 @ResponseStatus,拒绝表现为 HTTP 200 + 信封 code=3(非 500)。
*/
private void checkAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -0,0 +1,62 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.util.Date;
/**
* 小组件候选池(widget_catalog 表):内容自持,全表无 html 地址概念。
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_catalog", uniqueConstraints = @UniqueConstraint(columnNames = "code"))
public class WidgetCatalog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 内置组件 = 前端 widgetRegistry 的键;自定义片段 = 自定义标识 */
@Column(name = "code", nullable = false, length = 32)
private String code;
@Column(name = "name", nullable = false, length = 64)
private String name;
/** 1 = front 工程内置组件(内容在代码库,本行不存 content);2 = 自定义 HTML 片段(内容在 content) */
@Column(name = "comp_type", nullable = false)
private Integer compType = 1;
/** 仅 comp_type=2:片段 HTML 源码 */
@Column(name = "content", columnDefinition = "mediumtext")
private String content;
/** 片段取数用的数据键,须在 WidgetDataService 白名单内 */
@Column(name = "data_code", length = 32)
private String dataCode;
/** 下发范围:NULL = 全校可见(本期只留字段与过滤钩子,v1 全校一致) */
@Column(name = "sch_id")
private Integer schId;
@Column(name = "sort", nullable = false)
private Integer sort = 0;
/** 1 = 进候选池,0 = 下线 */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
@Column(name = "update_time")
private Date updateTime;
}
@@ -0,0 +1,20 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetCatalog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, Integer>,
JpaSpecificationExecutor<WidgetCatalog> {
Optional<WidgetCatalog> findByCode(String code);
List<WidgetCatalog> findByEnabledOrderBySortAscIdAsc(Integer enabled);
List<WidgetCatalog> findAllByOrderBySortAscIdAsc();
}
@@ -0,0 +1,22 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.WidgetCatalog;
import java.util.List;
import java.util.Optional;
public interface IWidgetCatalogService {
/** 候选池(登录侧):enabled=1 且对本校可见,按 sort 升序 */
List<WidgetCatalog> findEnabledForSchool(Integer schId);
Optional<WidgetCatalog> findByCode(String code);
/** 下发范围判定:sch_id 为 NULL = 全校可见 */
boolean visibleToSchool(WidgetCatalog catalog, Integer schId);
/** 管理端列表:全量(含 content 与 update_time),鉴权在 Controller */
List<WidgetCatalog> findAllForAdmin();
void save(WidgetCatalog model);
}
@@ -0,0 +1,152 @@
package com.yunhai.userhome.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 小组件统一取数(方案 §3.4 已拍板:先按 L2 可溯源 mock)。
*
* <p>字段名逐一等于 design/widget-snapshots-old-bsch/*.js 的旧接口口径,接入真实数据源前不外扩;
* 未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
*/
@Service
public class WidgetDataService {
private static final Logger log = LoggerFactory.getLogger(WidgetDataService.class);
private static final String CODE_EVALUATE = "evaluate";
private static final String CODE_SYLLABUS = "syllabus";
private static final String CODE_WEEKPLAN = "weekplan";
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
private static final int EVALUATE_DEFAULT_COUNT = 5;
/**
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
*
* @return 数据结构由数据键决定;null = 该键未接入
*/
public Object getData(String code, String params, Integer schId) {
if (CODE_EVALUATE.equals(code)) return evaluate(params);
if (CODE_SYLLABUS.equals(code)) return syllabus();
if (CODE_WEEKPLAN.equals(code)) return weekplan(params, schId);
log.warn("[widget] 未配置的数据键:{}", code);
return null;
}
/**
* 听评课:分页 {list, count, pageSize},入参 {page, count}(page 从 0 起)。
* // TODO 接入 oapi: openCall.post(TARGET_USERBASE, "/oapi/...", null, body, Vo.class)
*/
private Map<String, Object> evaluate(String params) {
int page = Math.max(0, intParam(params, "page", 0));
int count = Math.min(50, Math.max(1, intParam(params, "count", EVALUATE_DEFAULT_COUNT)));
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("id", 101, "title", "《圆的认识》课堂观察",
"lectureTime", "2026-09-28 08:30:00", "lectureteaName", "王晓梅", "subName", "数学"));
rows.add(row("id", 102, "title", "《Unit 5 Reading》公开课",
"lectureTime", "2026-09-29 10:00:00", "lectureteaName", "李强", "subName", "英语"));
rows.add(row("id", 103, "title", "《光的折射》实验课",
"lectureTime", "2026-09-30 14:00:00", "lectureteaName", "陈雷", "subName", "物理"));
rows.add(row("id", 104, "title", "《三角形内角和》研讨课",
"lectureTime", "2026-10-09 09:00:00", "lectureteaName", "赵敏", "subName", "数学"));
int from = Math.min(page * count, rows.size());
int to = Math.min(from + count, rows.size());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", rows.subList(from, to));
result.put("count", rows.size());
result.put("pageSize", count);
return result;
}
/**
* 我的课表:数组,一格一行(节次 CourseTimes × 星期 Weeks)。
* // TODO 接入 oapi: openCall.getList(TARGET_USERBASE, "/oapi/...", null, Vo.class)
*/
private List<Map<String, Object>> syllabus() {
List<Map<String, Object>> rows = new ArrayList<>();
addCourse(rows, 1, "第1节", "数学", 1, "周一", "高一(3)班");
addCourse(rows, 1, "第1节", "数学", 2, "周二", "高一(4)班");
addCourse(rows, 1, "第1节", "数学", 4, "周四", "高一(3)班");
addCourse(rows, 2, "第2节", "英语", 2, "周二", "高一(5)班");
addCourse(rows, 2, "第2节", "英语", 3, "周三", "高一(4)班");
addCourse(rows, 2, "第2节", "英语", 5, "周五", "高一(5)班");
addCourse(rows, 3, "第3节", "物理", 1, "周一", "高一(6)班");
addCourse(rows, 3, "第3节", "物理", 3, "周三", "高一(6)班");
addCourse(rows, 3, "第3节", "物理", 4, "周四", "高一(4)班");
addCourse(rows, 4, "第4节", "体育", 3, "周三", "高一(3)班");
addCourse(rows, 4, "第4节", "体育", 5, "周五", "高一(6)班");
return rows;
}
private void addCourse(List<Map<String, Object>> rows, int courseTimes, String courseName, String subName,
int weeks, String weekName, String className) {
rows.add(row("CourseName", courseName, "CourseTimes", courseTimes, "Weeks", weeks,
"weekName", weekName, "SubName", subName, "ClassName", className));
}
/**
* 一周安排:{Data:[{date, list:[{contents, times}]}], OtherData:{week, schId}},入参 iweek(-1/0/1)。
* // TODO 接入 oapi: openCall.get(TARGET_USERBASE, "/oapi/...", query, Vo.class)
*/
private Map<String, Object> weekplan(String params, Integer schId) {
int iweek = Math.max(-1, Math.min(1, intParam(params, "iweek", 0)));
LocalDate monday = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.plusWeeks(iweek);
List<Map<String, Object>> data = new ArrayList<>();
for (int day = 0; day < 7; day++) {
List<Map<String, Object>> items = new ArrayList<>();
if (day == 0) {
items.add(row("contents", "行政会(三楼会议室)", "times", "09:00-10:00"));
} else if (day == 2) {
items.add(row("contents", "数学教研组备课磨课", "times", "14:00-15:30"));
} else if (day == 4) {
items.add(row("contents", "家长开放日准备会", "times", "16:00-17:00"));
}
data.add(row("date", monday.plusDays(day).format(DATE_FORMAT), "list", items));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("Data", data);
// 真实教学周次由数据源下发,mock 固定第 7 周
result.put("OtherData", row("week", 7, "schId", schId));
return result;
}
private int intParam(String params, String key, int defaultValue) {
if (params == null || params.isEmpty()) return defaultValue;
for (String pair : params.split("&")) {
int split = pair.indexOf('=');
if (split <= 0 || !key.equals(pair.substring(0, split))) continue;
try {
return Integer.parseInt(URLDecoder.decode(pair.substring(split + 1), "UTF-8").trim());
} catch (NumberFormatException | UnsupportedEncodingException e) {
return defaultValue;
}
}
return defaultValue;
}
private Map<String, Object> row(Object... keyValues) {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < keyValues.length; i += 2) {
map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
}
return map;
}
}
@@ -0,0 +1,79 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.repository.WidgetCatalogRepository;
import com.yunhai.userhome.service.IWidgetCatalogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 小组件候选池服务(对齐 IUserSettingsService 分层:Controller 只做鉴权与出参组装,规则在此)。
*/
@Service
public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
private static final int COMP_TYPE_BUILTIN = 1;
private static final int COMP_TYPE_FRAGMENT = 2;
private static final int ENABLED = 1;
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Override
public List<WidgetCatalog> findEnabledForSchool(Integer schId) {
return widgetCatalogRepository.findByEnabledOrderBySortAscIdAsc(ENABLED).stream()
.filter(model -> visibleToSchool(model, schId))
.collect(Collectors.toList());
}
@Override
public Optional<WidgetCatalog> findByCode(String code) {
if (code == null || code.trim().isEmpty()) {
return Optional.empty();
}
return widgetCatalogRepository.findByCode(code);
}
@Override
public boolean visibleToSchool(WidgetCatalog catalog, Integer schId) {
return catalog.getSchId() == null || catalog.getSchId().equals(schId);
}
@Override
public List<WidgetCatalog> findAllForAdmin() {
return widgetCatalogRepository.findAllByOrderBySortAscIdAsc();
}
@Override
public void save(WidgetCatalog model) {
if (model.getCode() == null || model.getCode().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件键 code");
}
if (model.getName() == null || model.getName().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少名称");
}
if (model.getCompType() == null
|| (model.getCompType() != COMP_TYPE_BUILTIN && model.getCompType() != COMP_TYPE_FRAGMENT)) {
throw new BizException(ErrorCode.CUSTOM, "类型只能是内置(1)或自定义片段(2)");
}
Optional<WidgetCatalog> exist = widgetCatalogRepository.findByCode(model.getCode());
if (exist.isPresent() && !exist.get().getId().equals(model.getId())) {
throw new BizException(ErrorCode.CUSTOM, "组件键已存在:" + model.getCode());
}
if (model.getSort() == null) model.setSort(0);
if (model.getEnabled() == null) model.setEnabled(ENABLED);
// 内置组件的内容在 front 工程里,库里只登记键
if (model.getCompType() == COMP_TYPE_BUILTIN) model.setContent(null);
model.setUpdateTime(new Date());
widgetCatalogRepository.save(model);
}
}
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 小组件候选池 widget_catalog(UserHome小组件内置化落地方案-20261009 §3.1)
-- 候选池真源由旧 bsch 开放接口换成本地表;内容自持,全表没有 url / html 地址字段。
-- comp_type=1 → 内容在 front 工程(内置 Vue 组件),本表只登记键;
-- comp_type=2 → 内容在 content(管理端贴/传,srcdoc 渲染)。
-- dev 由 JPA ddl-auto=update 依实体自动建表;测试/生产等关闭 ddl-auto 的环境手工执行本文件。
-- ============================================================
CREATE TABLE IF NOT EXISTS `widget_catalog` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(32) NOT NULL COMMENT '组件键:内置=前端注册表键,自定义=自定义标识',
`name` VARCHAR(64) NOT NULL COMMENT '展示名(候选池文案 / 面板标题)',
`comp_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=front 内置组件 2=自定义 HTML 片段',
`content` MEDIUMTEXT NULL COMMENT 'comp_type=2 的 HTML 源码;内置类型恒空',
`data_code` VARCHAR(32) NULL COMMENT '片段取数用的数据键(须在 WidgetDataService 白名单内)',
`sch_id` INT NULL COMMENT '下发范围:NULL=全校可见(本期全校一致,字段与过滤钩子先就位)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '候选池排序',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1=进候选池 0=下线',
`update_time` DATETIME NULL COMMENT '内容最后修改时间(管理端展示)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_catalog_code` (`code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件候选池(内容自持,无外部 html 托管依赖)';
-- 初始化三个存量组件(判定规则 §9:全部内置,content 留空)
-- code 上有唯一键,用 INSERT IGNORE 保证脚本可重复执行
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('evaluate', '听评课', 1, NULL, NULL, NULL, 1, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('syllabus', '我的课表', 1, NULL, NULL, NULL, 2, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('weekplan', '一周安排', 1, NULL, NULL, NULL, 3, 1, NOW());
-- 回滚:
-- DROP TABLE IF EXISTS `widget_catalog`;