批量推送

This commit is contained in:
郭庆泰
2026-08-16 21:24:53 +08:00
parent 0c093076a4
commit ccfd03a6eb
55 changed files with 3772 additions and 577 deletions
+4
View File
@@ -20,6 +20,10 @@
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-framework</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-system</artifactId>
@@ -0,0 +1,69 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.business.domain.BizArticle;
import com.ruoyi.business.service.IBizArticleService;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 平台协议/隐私政策文章 Controller (后台 admin)
*/
@RestController
@RequestMapping("/business/article")
public class BizArticleController extends BaseController
{
@Autowired
private IBizArticleService articleService;
@PreAuthorize("@ss.hasPermi('business:article:list')")
@GetMapping("/list")
public TableDataInfo list(BizArticle entity)
{
startPage();
List<BizArticle> list = articleService.selectList(entity);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('business:article:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(articleService.getById(id));
}
@PreAuthorize("@ss.hasPermi('business:article:add')")
@Log(title = "平台协议", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizArticle entity)
{
if (entity.getStatus() == null) entity.setStatus("0");
entity.setCreateBy(SecurityUtils.getUsername());
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(articleService.insert(entity));
}
@PreAuthorize("@ss.hasPermi('business:article:edit')")
@Log(title = "平台协议", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizArticle entity)
{
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(articleService.updateByPrimaryKey(entity));
}
@PreAuthorize("@ss.hasPermi('business:article:remove')")
@Log(title = "平台协议", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(articleService.deleteByPrimaryKeys(ids));
}
}
@@ -2,6 +2,9 @@ package com.ruoyi.business.controller;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -11,9 +14,19 @@ import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.dto.SmsValidForm;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.enums.UserStatus;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
/**
@@ -27,6 +40,10 @@ import com.ruoyi.system.service.ISysUserService;
* 注: 主账号不建 biz_person
*
* 2026-08-16 重构: 删 biz_user_role_bind, 统一用 sys_user.role_type (见迁移脚本)
*
* 2026-08-16 新增: 短信验证码登录 (loginMode=sms):
* 1. smsSendCode 发送短信 (校验手机号已注册)
* 2. smsLogin 校验短信验证码 + 颁发 JWT token (不走 AuthenticationManager, 因为无密码)
*/
@RestController
@RequestMapping("/business/auth")
@@ -44,6 +61,15 @@ public class BizAuthController extends BaseController {
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Autowired
private SysUserMapper userMapper;
@Autowired
private TokenService tokenService;
@Autowired
private SysPermissionService permissionService;
@PostMapping("/login")
public AjaxResult login(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
@@ -52,6 +78,82 @@ public class BizAuthController extends BaseController {
return success().put("roleType", roleType).put("token", "mock-token");
}
/**
* 登录用 - 发送短信验证码 (手机号必须已注册)
*/
@PostMapping("/smsSendCode")
public AjaxResult smsSendCode(@RequestBody Map<String, String> body) {
String phone = body == null ? null : body.get("phone");
if (phone == null || !phone.matches("^1\\d{10}$")) {
return error("请输入正确的手机号");
}
// 登录反义: 手机号未注册则不发
if (!userService.isPhoneRegistered(phone)) {
return error("该手机号未注册, 请先注册账号");
}
String uuid = smsService.sendCode(phone);
return AjaxResult.success("验证码已发送", uuid);
}
/**
* 短信验证码登录 (无密码)
* 1. 校验手机号 + 短信验证码
* 2. 查用户 (按手机号)
* 3. 校验用户状态
* 4. 构造 LoginUser, 颁发 JWT token (SecurityContext 一并设置, 兼容后续鉴权)
* 5. 记录登录信息 + logininfor
*/
@PostMapping("/smsLogin")
public AjaxResult smsLogin(@RequestBody Map<String, String> body) {
String phone = body == null ? null : body.get("phone");
String code = body == null ? null : body.get("smsCode");
String uuid = body == null ? null : body.get("uuid");
// 1. 基础校验
if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误");
if (code == null || code.isEmpty()) return error("请输入短信验证码");
// 2. 校验短信验证码
SmsValidForm smsForm = new SmsValidForm();
smsForm.setPhone(phone);
smsForm.setSmsCode(code);
smsForm.setUuid(uuid);
try {
smsService.verifyCode(smsForm);
} catch (RuntimeException e) {
return error("验证码错误或已过期: " + e.getMessage());
}
// 3. 查用户 (mapper.checkPhoneUnique 返回 SysUser 或 null)
SysUser user = userMapper.checkPhoneUnique(phone);
if (user == null || user.getUserId() == null) {
return error("该手机号未注册");
}
// 4. 校验用户状态
if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
return error("账号已被删除");
}
if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
return error("账号已被停用");
}
// 5. 构造 LoginUser 并设 SecurityContext (兼容后续 spring security 鉴权)
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
Authentication authentication = new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
// 6. 记录登录信息 + logininfor
AsyncManager.me().execute(AsyncFactory.recordLogininfor(user.getUserName(), Constants.LOGIN_SUCCESS,
"短信验证码登录成功"));
userService.updateLoginInfo(user.getUserId(), IpUtils.getIpAddr(), DateUtils.getNowDate());
// 7. 颁发 token
String token = tokenService.createToken(loginUser);
return AjaxResult.success("登录成功").put(Constants.TOKEN, token);
}
@PostMapping("/registerExecutor")
public AjaxResult registerExecutor(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
@@ -39,8 +39,9 @@ public class BizExecutorController extends BaseController
@RequestParam(required = false, defaultValue = "50") Integer pageSize)
{
StringBuilder sql = new StringBuilder()
// 仅返回 MAIN 主账号: 排除 SUB 子账号, 避免项目分配下拉混入下属执行人
.append("SELECT user_id, dept_id, user_name, nick_name, email, phonenumber, role_type, status, create_time ")
.append("FROM sys_user WHERE del_flag = '0' AND status = '0' AND role_type = ?");
.append("FROM sys_user WHERE del_flag = '0' AND status = '0' AND role_type = ? AND account_type = 'MAIN'");
List<Object> args = new ArrayList<>();
args.add(roleType);
if (userId != null) {
@@ -0,0 +1,90 @@
package com.ruoyi.business.controller;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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.RestController;
import com.ruoyi.business.dto.SmsValidForm;
import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
/**
* 忘记密码 - 通过短信验证码重置密码 (匿名访问)
*
* 流程:
* 1. forgotSendSms 发送短信 (校验手机号已注册)
* 2. resetPassword 校验短信验证码 + 重置密码 (一次提交, 简化前端)
*/
@RestController
@RequestMapping("/business/auth")
public class BizForgotController extends BaseController {
@Autowired
private SysSmsService smsService;
@Autowired
private ISysUserService userService;
@Autowired
private SysUserMapper userMapper;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@PostMapping("/forgotSendSms")
public AjaxResult forgotSendSms(@RequestBody Map<String, String> body) {
String phone = body == null ? null : body.get("phone");
if (phone == null || !phone.matches("^1\\d{10}$")) {
return error("请输入正确的手机号");
}
// 手机号必须已注册 (忘记密码反义: 该号查不到则不发)
if (!userService.isPhoneRegistered(phone)) {
return error("该手机号未注册, 请先注册账号");
}
String uuid = smsService.sendCode(phone);
return AjaxResult.success("验证码已发送", uuid);
}
@PostMapping("/resetPassword")
public AjaxResult resetPassword(@RequestBody Map<String, Object> body) {
String phone = (String) body.get("phone");
String code = (String) body.get("smsCode");
String uuid = (String) body.get("uuid");
String password = (String) body.get("password");
String confirmPassword = (String) body.get("confirmPassword");
// 1. 基础校验
if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误");
if (code == null || code.isEmpty()) return error("请输入短信验证码");
if (password == null || password.length() < 6 || password.length() > 20) return error("密码长度 6-20 位");
if (!password.equals(confirmPassword)) return error("两次密码输入不一致");
// 2. 校验短信验证码
SmsValidForm smsForm = new SmsValidForm();
smsForm.setPhone(phone);
smsForm.setSmsCode(code);
smsForm.setUuid(uuid);
try {
smsService.verifyCode(smsForm);
} catch (RuntimeException e) {
return error("验证码错误或已过期: " + e.getMessage());
}
// 3. 查用户 (mapper.checkPhoneUnique 返回 SysUser 或 null, IService 包装成 boolean 不能用)
SysUser info = userMapper.checkPhoneUnique(phone);
if (info == null || info.getUserId() == null) {
return error("该手机号未注册");
}
// 4. 重置密码
int rows = userService.resetUserPwd(info.getUserId(), passwordEncoder.encode(password));
return rows > 0 ? success("密码重置成功, 请用新密码登录") : error("密码重置失败");
}
}
@@ -10,14 +10,18 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.domain.BizArticle;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizSupportLetter;
import com.ruoyi.business.domain.BizInvitation;
import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.service.IBizArticleService;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizSupportLetterService;
import com.ruoyi.business.service.IBizInvitationService;
import com.ruoyi.business.service.IBizProjectPlanService;
import com.ruoyi.business.service.IBizSpecialPlanService;
import com.ruoyi.business.domain.BizSpecialPlan;
/**
* 公开门户接口(无需登录)
@@ -30,6 +34,38 @@ public class BizPublicController extends BaseController {
@Autowired private IBizSupportLetterService supportLetterService;
@Autowired private IBizInvitationService invitationService;
@Autowired private IBizProjectPlanService projectPlanService;
@Autowired private IBizArticleService articleService;
@Autowired private IBizSpecialPlanService specialPlanService;
/**
* 按类型获取已发布的协议/政策 (供注册页底部链接打开)
* type: agreement | privacy
*/
@GetMapping("/article/{type}")
public AjaxResult articleByType(@PathVariable("type") String type)
{
BizArticle article = articleService.getByType(type);
return success(article);
}
/**
* 公开接口 - 七大专项计划 (首页用)
*/
@GetMapping("/specialPlan/list")
public AjaxResult specialPlanList() {
return success(specialPlanService.selectPublicList());
}
/**
* 公开接口 - 专项计划详情 (供详情页)
* 注意: id 可能是临时id (小于0 = 新建未保存), 也支持按 序号 (sortOrder) 查
*/
@GetMapping("/specialPlan/{id}")
public AjaxResult specialPlanById(@PathVariable("id") Long id) {
BizSpecialPlan plan = specialPlanService.getById(id);
if (plan == null) return error("专项计划不存在");
return success(plan);
}
@GetMapping("/index")
public AjaxResult index() {
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizSpecialPlan;
import com.ruoyi.business.service.IBizSpecialPlanService;
/**
* 七大专项计划 Controller (后台 admin)
*/
@RestController
@RequestMapping("/business/specialPlan")
public class BizSpecialPlanController extends BaseController
{
@Autowired
private IBizSpecialPlanService specialPlanService;
@PreAuthorize("@ss.hasPermi('business:specialPlan:list')")
@GetMapping("/list")
public TableDataInfo list(BizSpecialPlan entity)
{
startPage();
List<BizSpecialPlan> list = specialPlanService.selectList(entity);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('business:specialPlan:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(specialPlanService.getById(id));
}
@PreAuthorize("@ss.hasPermi('business:specialPlan:add')")
@Log(title = "七大专项计划", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizSpecialPlan entity)
{
if (entity.getContentType() == null) entity.setContentType("rich");
if (entity.getStatus() == null) entity.setStatus("0");
if (entity.getSortOrder() == null) entity.setSortOrder(0);
entity.setCreateBy(SecurityUtils.getUsername());
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(specialPlanService.insert(entity));
}
@PreAuthorize("@ss.hasPermi('business:specialPlan:edit')")
@Log(title = "七大专项计划", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizSpecialPlan entity)
{
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(specialPlanService.updateByPrimaryKey(entity));
}
@PreAuthorize("@ss.hasPermi('business:specialPlan:remove')")
@Log(title = "七大专项计划", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(specialPlanService.deleteByPrimaryKeys(ids));
}
}
@@ -0,0 +1,41 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/** 平台协议/隐私政策文章对象 BizArticle */
public class BizArticle extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 文章ID */
private Long id;
/** 标题 */
@Excel(name = "标题")
private String title;
/** 类型 agreement=用户协议 privacy=隐私政策 */
@Excel(name = "类型", readConverterExp = "agreement=用户协议,privacy=隐私政策")
private String type;
/** 正文 (HTML/富文本) */
@Excel(name = "正文")
private String content;
/** 状态 0启用 1停用 */
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
private String status;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
@@ -58,7 +58,7 @@ public class BizMeeting extends BaseEntity {
private Long projectId;
/** 项目名称 */
private String projectName;
/** 所属公司名称 (冗余字段, 由 biz_project.org_name 同步) */
/** 所属公司名称 (冗余字段, 由 biz_project.sponsor_admin_user_name 同步) */
private String orgName;
/** 监察意见 */
private String supervisionOpinion;
@@ -49,12 +49,11 @@ public class BizProject extends BaseEntity {
private BigDecimal ratingQ3;
/** 评分维度: 合规安全 */
private BigDecimal ratingQ4;
/** org_name (赞助方/执行方, 由 org_type 区分) */
@Excel(name = "org_name")
private String orgName;
/** org_type: sponsor 赞助方 / executor 执行方 */
@Excel(name = "org_type")
private String orgType;
/** 赞助方负责人用户名(冗余) — 原 org_name */
@Excel(name = "sponsor_admin_user_name")
private String sponsorAdminUserName;
/** 服务机构名称 (查询条件, 仅匹配 org_type='executor' + org_name LIKE) */
private String execOrgName;
/** project_form */
@Excel(name = "project_form")
private String projectForm;
@@ -64,9 +63,9 @@ public class BizProject extends BaseEntity {
/** is_settled */
@Excel(name = "is_settled")
private String isSettled;
/** org_id (赞助方/执行方, 由 org_type 区分) */
@Excel(name = "org_id")
private Long orgId;
/** 赞助方负责人用户ID (sys_user.user_id, role_type=sponsor) — 原 org_id */
@Excel(name = "sponsor_admin_user_id")
private Long sponsorAdminUserId;
/** create_by */
@Excel(name = "create_by")
private String createBy;
@@ -149,18 +148,18 @@ public class BizProject extends BaseEntity {
public void setRatingQ3(BigDecimal ratingQ3) { this.ratingQ3 = ratingQ3; }
public BigDecimal getRatingQ4() { return ratingQ4; }
public void setRatingQ4(BigDecimal ratingQ4) { this.ratingQ4 = ratingQ4; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public String getOrgType() { return orgType; }
public void setOrgType(String orgType) { this.orgType = orgType; }
public String getSponsorAdminUserName() { return sponsorAdminUserName; }
public void setSponsorAdminUserName(String sponsorAdminUserName) { this.sponsorAdminUserName = sponsorAdminUserName; }
public String getExecOrgName() { return execOrgName; }
public void setExecOrgName(String execOrgName) { this.execOrgName = execOrgName; }
public String getProjectForm() { return projectForm; }
public void setProjectForm(String projectForm) { this.projectForm = projectForm; }
public String getIsFinished() { return isFinished; }
public void setIsFinished(String isFinished) { this.isFinished = isFinished; }
public String getIsSettled() { return isSettled; }
public void setIsSettled(String isSettled) { this.isSettled = isSettled; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public Long getSponsorAdminUserId() { return sponsorAdminUserId; }
public void setSponsorAdminUserId(Long sponsorAdminUserId) { this.sponsorAdminUserId = sponsorAdminUserId; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public Date getCreateTime() { return createTime; }
@@ -0,0 +1,51 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/** 七大专项计划对象 BizSpecialPlan */
public class BizSpecialPlan extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 专项计划ID */
private Long id;
/** 专项计划名称 */
@Excel(name = "名称")
private String title;
/** 内容类型 rich=富文本 file=上传文件 */
@Excel(name = "类型", readConverterExp = "rich=富文本,file=上传文件")
private String contentType;
/** 富文本内容 */
private String content;
/** 上传文件URL (PDF/PNG) */
private String fileUrl;
/** 排序 */
@Excel(name = "排序")
private Integer sortOrder;
/** 状态 0启用 1停用 */
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
private String status;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContentType() { return contentType; }
public void setContentType(String contentType) { this.contentType = contentType; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getFileUrl() { return fileUrl; }
public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
@@ -0,0 +1,15 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizArticle;
public interface BizArticleMapper
{
BizArticle selectByPrimaryKey(Long id);
List<BizArticle> selectList(BizArticle entity);
BizArticle selectByType(String type);
int insert(BizArticle entity);
int updateByPrimaryKey(BizArticle entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,20 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizSpecialPlan;
public interface BizSpecialPlanMapper
{
BizSpecialPlan selectByPrimaryKey(Long id);
/** 仅查 status='0' 的, 用于首页 / 公开页 */
List<BizSpecialPlan> selectPublicList();
/** 后台管理: 按条件查全部 */
List<BizSpecialPlan> selectList(BizSpecialPlan entity);
int insert(BizSpecialPlan entity);
int updateByPrimaryKey(BizSpecialPlan entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,18 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizArticle;
/**
* 平台协议/隐私政策文章 Service 接口
*/
public interface IBizArticleService
{
BizArticle getById(Long id);
List<BizArticle> selectList(BizArticle entity);
BizArticle getByType(String type);
int insert(BizArticle entity);
int updateByPrimaryKey(BizArticle entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,18 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizSpecialPlan;
/**
* 七大专项计划 Service 接口
*/
public interface IBizSpecialPlanService
{
BizSpecialPlan getById(Long id);
List<BizSpecialPlan> selectPublicList();
List<BizSpecialPlan> selectList(BizSpecialPlan entity);
int insert(BizSpecialPlan entity);
int updateByPrimaryKey(BizSpecialPlan entity);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,60 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizArticle;
import com.ruoyi.business.mapper.BizArticleMapper;
import com.ruoyi.business.service.IBizArticleService;
/**
* 平台协议/隐私政策文章 Service 业务层
*/
@Service
public class BizArticleServiceImpl implements IBizArticleService
{
@Autowired
private BizArticleMapper articleMapper;
@Override
public BizArticle getById(Long id)
{
return articleMapper.selectByPrimaryKey(id);
}
@Override
public List<BizArticle> selectList(BizArticle entity)
{
return articleMapper.selectList(entity);
}
@Override
public BizArticle getByType(String type)
{
return articleMapper.selectByType(type);
}
@Override
public int insert(BizArticle entity)
{
return articleMapper.insert(entity);
}
@Override
public int updateByPrimaryKey(BizArticle entity)
{
return articleMapper.updateByPrimaryKey(entity);
}
@Override
public int deleteByPrimaryKey(Long id)
{
return articleMapper.deleteByPrimaryKey(id);
}
@Override
public int deleteByPrimaryKeys(Long[] ids)
{
return articleMapper.deleteByPrimaryKeys(ids);
}
}
@@ -0,0 +1,39 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizSpecialPlan;
import com.ruoyi.business.mapper.BizSpecialPlanMapper;
import com.ruoyi.business.service.IBizSpecialPlanService;
/**
* 七大专项计划 Service 业务层
*/
@Service
public class BizSpecialPlanServiceImpl implements IBizSpecialPlanService
{
@Autowired
private BizSpecialPlanMapper specialPlanMapper;
@Override
public BizSpecialPlan getById(Long id) { return specialPlanMapper.selectByPrimaryKey(id); }
@Override
public List<BizSpecialPlan> selectPublicList() { return specialPlanMapper.selectPublicList(); }
@Override
public List<BizSpecialPlan> selectList(BizSpecialPlan entity) { return specialPlanMapper.selectList(entity); }
@Override
public int insert(BizSpecialPlan entity) { return specialPlanMapper.insert(entity); }
@Override
public int updateByPrimaryKey(BizSpecialPlan entity) { return specialPlanMapper.updateByPrimaryKey(entity); }
@Override
public int deleteByPrimaryKey(Long id) { return specialPlanMapper.deleteByPrimaryKey(id); }
@Override
public int deleteByPrimaryKeys(Long[] ids) { return specialPlanMapper.deleteByPrimaryKeys(ids); }
}
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizArticleMapper">
<resultMap id="BaseResultMap" type="BizArticle">
<id property="id" column="id" />
<result property="title" column="title" />
<result property="type" column="type" />
<result property="content" column="content" />
<result property="status" column="status" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectFields">
select id, title, type, content, status, create_by, create_time, update_by, update_time, remark
from biz_article
</sql>
<select id="selectByPrimaryKey" parameterType="Long" resultMap="BaseResultMap">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" parameterType="BizArticle" resultMap="BaseResultMap">
<include refid="selectFields"/>
<where>
<if test="type != null and type != ''"> and type = #{type}</if>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by type asc, create_time desc
</select>
<select id="selectByType" parameterType="String" resultMap="BaseResultMap">
<include refid="selectFields"/>
where type = #{type} and status = '0'
limit 1
</select>
<insert id="insert" parameterType="BizArticle" useGeneratedKeys="true" keyProperty="id">
insert into biz_article
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="title != null and title != ''">title,</if>
<if test="type != null and type != ''">type,</if>
<if test="content != null">content,</if>
<if test="status != null">status,</if>
<if test="createBy != null">create_by,</if>
create_time,
<if test="updateBy != null">update_by,</if>
update_time,
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="title != null and title != ''">#{title},</if>
<if test="type != null and type != ''">#{type},</if>
<if test="content != null">#{content},</if>
<if test="status != null">#{status},</if>
<if test="createBy != null">#{createBy},</if>
sysdate(),
<if test="updateBy != null">#{updateBy},</if>
sysdate(),
<if test="remark != null">#{remark},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizArticle">
update biz_article
<trim prefix="SET" suffixOverrides=",">
<if test="title != null and title != ''">title = #{title},</if>
<if test="type != null and type != ''">type = #{type},</if>
<if test="content != null">content = #{content},</if>
<if test="status != null">status = #{status},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate(),
<if test="remark != null">remark = #{remark},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_article where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long[]">
delete from biz_article where id in
<foreach collection="array" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -17,12 +17,12 @@
<result property="ratingQ2" column="rating_q2" />
<result property="ratingQ3" column="rating_q3" />
<result property="ratingQ4" column="rating_q4" />
<result property="orgName" column="org_name" />
<result property="orgType" column="org_type" />
<result property="sponsorAdminUserName" column="sponsor_admin_user_name" />
<result property="sponsorAdminUserId" column="sponsor_admin_user_id" />
<result property="projectForm" column="project_form" />
<result property="isFinished" column="is_finished" />
<result property="isSettled" column="is_settled" />
<result property="orgId" column="org_id" />
<result property="sponsorAdminUserId" column="sponsor_admin_user_id" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -43,7 +43,7 @@
<result property="announcementType" column="announcement_type" />
</resultMap>
<sql id="selectFields">
select project_id, project_no, project_name, total_sessions, done_sessions, todo_sessions, total_amount, available_amount, paid_labor_amount, paid_meeting_amount, rating_score, rating_q1, rating_q2, rating_q3, rating_q4, org_name, org_type, project_form, is_finished, is_settled, org_id, create_by, create_time, update_by, update_time, manage_fee, start_time, end_time, submit_deadline_days, support_contract_url, execute_contract_url, invitation_url, support_letter_url, publish_url, notice_url, schedule_url, is_published, publish_time, announcement_type
select project_id, project_no, project_name, total_sessions, done_sessions, todo_sessions, total_amount, available_amount, paid_labor_amount, paid_meeting_amount, rating_score, rating_q1, rating_q2, rating_q3, rating_q4, sponsor_admin_user_name, project_form, is_finished, is_settled, sponsor_admin_user_id, create_by, create_time, update_by, update_time, manage_fee, start_time, end_time, submit_deadline_days, support_contract_url, execute_contract_url, invitation_url, support_letter_url, publish_url, notice_url, schedule_url, is_published, publish_time, announcement_type
from biz_project
</sql>
@@ -51,8 +51,8 @@
<sql id="selectFieldsForSponsor">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions,
p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount,
p.org_name, p.org_type, p.project_form, p.is_finished, p.is_settled,
p.org_id,
p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled,
p.sponsor_admin_user_id,
p.create_by, p.create_time, p.update_by, p.update_time, p.manage_fee,
p.start_time, p.end_time, p.submit_deadline_days,
p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url,
@@ -107,8 +107,26 @@
<if test="projectNo != null and projectNo != ''">and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and project_name like concat('%', #{projectName}, '%')</if>
<if test="projectForm != null and projectForm != ''">and project_form = #{projectForm}</if>
<if test="orgName != null and orgName != ''">and org_name like concat('%', #{orgName}, '%')</if>
<if test="orgType != null and orgType != ''">and org_type = #{orgType}</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">
and exists (
select 1 from sys_user su
where su.user_id = sponsor_admin_user_id
and su.role_type = 'sponsor'
and (su.user_name like concat('%', #{sponsorAdminUserName}, '%')
or su.nick_name like concat('%', #{sponsorAdminUserName}, '%'))
)
</if>
<if test="execOrgName != null and execOrgName != ''">
and exists (
select 1 from biz_project_assign bpa
join sys_user su on su.user_id = bpa.exec_user_id
where bpa.project_id = project_id
and su.role_type = 'executor'
and (su.user_name like concat('%', #{execOrgName}, '%')
or su.nick_name like concat('%', #{execOrgName}, '%')
or bpa.exec_org like concat('%', #{execOrgName}, '%'))
)
</if>
<if test="isFinished != null and isFinished != ''">and is_finished = #{isFinished}</if>
<if test="isSettled != null and isSettled != ''">and is_settled = #{isSettled}</if>
<if test="isPublished != null and isPublished != ''">and is_published = #{isPublished}</if>
@@ -133,12 +151,11 @@
<if test="ratingQ2 != null">rating_q2,</if>
<if test="ratingQ3 != null">rating_q3,</if>
<if test="ratingQ4 != null">rating_q4,</if>
<if test="orgName != null and orgName != ''">org_name,</if>
<if test="orgType != null and orgType != ''">org_type,</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">sponsor_admin_user_name,</if>
<if test="projectForm != null and projectForm != ''">project_form,</if>
<if test="isFinished != null and isFinished != ''">is_finished,</if>
<if test="isSettled != null and isSettled != ''">is_settled,</if>
<if test="orgId != null">org_id,</if>
<if test="sponsorAdminUserId != null">sponsor_admin_user_id,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null">update_by,</if>
@@ -174,12 +191,11 @@
<if test="ratingQ2 != null">#{ratingQ2},</if>
<if test="ratingQ3 != null">#{ratingQ3},</if>
<if test="ratingQ4 != null">#{ratingQ4},</if>
<if test="orgName != null and orgName != ''">#{orgName},</if>
<if test="orgType != null and orgType != ''">#{orgType},</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">#{sponsorAdminUserName},</if>
<if test="projectForm != null and projectForm != ''">#{projectForm},</if>
<if test="isFinished != null and isFinished != ''">#{isFinished},</if>
<if test="isSettled != null and isSettled != ''">#{isSettled},</if>
<if test="orgId != null">#{orgId},</if>
<if test="sponsorAdminUserId != null">#{sponsorAdminUserId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null">#{updateBy},</if>
@@ -231,12 +247,11 @@
<if test="ratingQ2 != null">rating_q2 = #{ratingQ2},</if>
<if test="ratingQ3 != null">rating_q3 = #{ratingQ3},</if>
<if test="ratingQ4 != null">rating_q4 = #{ratingQ4},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="orgType != null and orgType != ''">org_type = #{orgType},</if>
<if test="sponsorAdminUserName != null and sponsorAdminUserName != ''">sponsor_admin_user_name = #{sponsorAdminUserName},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="isFinished != null and isFinished != ''">is_finished = #{isFinished},</if>
<if test="isSettled != null and isSettled != ''">is_settled = #{isSettled},</if>
<if test="orgId != null">org_id = #{orgId},</if>
<if test="sponsorAdminUserId != null">sponsor_admin_user_id = #{sponsorAdminUserId},</if>
<if test="createBy != null">create_by = #{createBy},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizSpecialPlanMapper">
<resultMap id="BaseResultMap" type="BizSpecialPlan">
<id property="id" column="id" />
<result property="title" column="title" />
<result property="contentType" column="content_type" />
<result property="content" column="content" />
<result property="fileUrl" column="file_url" />
<result property="sortOrder" column="sort_order" />
<result property="status" column="status" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectFields">
select id, title, content_type, content, file_url, sort_order, status,
create_by, create_time, update_by, update_time, remark
from biz_special_plan
</sql>
<select id="selectByPrimaryKey" parameterType="Long" resultMap="BaseResultMap">
<include refid="selectFields"/>
where id = #{id}
</select>
<!-- 公开: 仅启用 (status=0), 按 sort_order 升序 -->
<select id="selectPublicList" resultMap="BaseResultMap">
<include refid="selectFields"/>
where status = '0'
order by sort_order asc, create_time asc
</select>
<!-- 后台管理: 支持 title/status 模糊/精确 -->
<select id="selectList" parameterType="BizSpecialPlan" resultMap="BaseResultMap">
<include refid="selectFields"/>
<where>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="contentType != null and contentType != ''"> and content_type = #{contentType}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by sort_order asc, id asc
</select>
<insert id="insert" parameterType="BizSpecialPlan" useGeneratedKeys="true" keyProperty="id">
insert into biz_special_plan
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="title != null and title != ''">title,</if>
<if test="contentType != null and contentType != ''">content_type,</if>
<if test="content != null">content,</if>
<if test="fileUrl != null">file_url,</if>
<if test="sortOrder != null">sort_order,</if>
<if test="status != null">status,</if>
<if test="createBy != null">create_by,</if>
create_time,
<if test="updateBy != null">update_by,</if>
update_time,
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="title != null and title != ''">#{title},</if>
<if test="contentType != null and contentType != ''">#{contentType},</if>
<if test="content != null">#{content},</if>
<if test="fileUrl != null">#{fileUrl},</if>
<if test="sortOrder != null">#{sortOrder},</if>
<if test="status != null">#{status},</if>
<if test="createBy != null">#{createBy},</if>
sysdate(),
<if test="updateBy != null">#{updateBy},</if>
sysdate(),
<if test="remark != null">#{remark},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizSpecialPlan">
update biz_special_plan
<trim prefix="SET" suffixOverrides=",">
<if test="title != null and title != ''">title = #{title},</if>
<if test="contentType != null and contentType != ''">content_type = #{contentType},</if>
<if test="content != null">content = #{content},</if>
<if test="fileUrl != null">file_url = #{fileUrl},</if>
<if test="sortOrder != null">sort_order = #{sortOrder},</if>
<if test="status != null">status = #{status},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate(),
<if test="remark != null">remark = #{remark},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_special_plan where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long[]">
delete from biz_special_plan where id in
<foreach collection="array" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
+493
View File
@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pinia": "^2.1.0",
@@ -63,6 +65,14 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
@@ -1169,12 +1179,22 @@
"win32"
]
},
"node_modules/@transloadit/prettier-bytes": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA=="
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
"node_modules/@types/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ=="
},
"node_modules/@types/lodash": {
"version": "4.17.25",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
@@ -1193,6 +1213,56 @@
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@uppy/companion-client": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz",
"integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
"dependencies": {
"@uppy/utils": "^4.1.2",
"namespace-emitter": "^2.0.1"
}
},
"node_modules/@uppy/core": {
"version": "2.3.4",
"resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
"dependencies": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/store-default": "^2.1.1",
"@uppy/utils": "^4.1.3",
"lodash.throttle": "^4.1.1",
"mime-match": "^1.0.2",
"namespace-emitter": "^2.0.1",
"nanoid": "^3.1.25",
"preact": "^10.5.13"
}
},
"node_modules/@uppy/store-default": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz",
"integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ=="
},
"node_modules/@uppy/utils": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz",
"integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
"dependencies": {
"lodash.throttle": "^4.1.1"
}
},
"node_modules/@uppy/xhr-upload": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
"dependencies": {
"@uppy/companion-client": "^2.2.2",
"@uppy/utils": "^4.1.2",
"nanoid": "^3.1.25"
},
"peerDependencies": {
"@uppy/core": "^2.3.3"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1335,6 +1405,156 @@
"vue": "^3.5.0"
}
},
"node_modules/@wangeditor/basic-modules": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
"dependencies": {
"is-url": "^1.2.4"
},
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/code-highlight": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
"integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
"dependencies": {
"prismjs": "^1.23.0"
},
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/core": {
"version": "1.1.19",
"resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz",
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
"dependencies": {
"@types/event-emitter": "^0.3.3",
"event-emitter": "^0.3.5",
"html-void-elements": "^2.0.0",
"i18next": "^20.4.0",
"scroll-into-view-if-needed": "^2.2.28",
"slate-history": "^0.66.0"
},
"peerDependencies": {
"@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3",
"dom7": "^3.0.0",
"is-hotkey": "^0.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.foreach": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lodash.toarray": "^4.4.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/editor": {
"version": "5.1.23",
"resolved": "https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz",
"integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
"dependencies": {
"@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3",
"@wangeditor/basic-modules": "^1.1.7",
"@wangeditor/code-highlight": "^1.0.3",
"@wangeditor/core": "^1.1.19",
"@wangeditor/list-module": "^1.0.5",
"@wangeditor/table-module": "^1.1.4",
"@wangeditor/upload-image-module": "^1.0.2",
"@wangeditor/video-module": "^1.1.4",
"dom7": "^3.0.0",
"is-hotkey": "^0.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.foreach": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lodash.toarray": "^4.4.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/editor-for-vue": {
"version": "5.1.12",
"resolved": "https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
"integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
"peerDependencies": {
"@wangeditor/editor": ">=5.1.0",
"vue": "^3.0.5"
}
},
"node_modules/@wangeditor/list-module": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz",
"integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/table-module": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz",
"integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/upload-image-module": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
"integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
"peerDependencies": {
"@uppy/core": "^2.0.3",
"@uppy/xhr-upload": "^2.0.3",
"@wangeditor/basic-modules": "1.x",
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"lodash.foreach": "^4.5.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/@wangeditor/video-module": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz",
"integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
"peerDependencies": {
"@uppy/core": "^2.1.4",
"@uppy/xhr-upload": "^2.0.7",
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
}
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
@@ -1537,6 +1757,11 @@
"node": ">= 0.8"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
@@ -1548,6 +1773,18 @@
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -1600,6 +1837,14 @@
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dom7": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
"dependencies": {
"ssr-window": "^3.0.0-alpha.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1695,6 +1940,43 @@
"node": ">= 0.4"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
@@ -1745,17 +2027,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.1.tgz",
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"dev": true
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1963,6 +2276,15 @@
"node": ">= 0.4"
}
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz",
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1975,6 +2297,23 @@
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "20.6.1",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz",
"integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
"dependencies": {
"@babel/runtime": "^7.12.0"
}
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
@@ -2037,6 +2376,11 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
@@ -2046,6 +2390,19 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -2099,6 +2456,42 @@
"lodash-es": "*"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ=="
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
},
"node_modules/lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw=="
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -2162,6 +2555,14 @@
"node": ">= 0.6"
}
},
"node_modules/mime-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz",
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
"dependencies": {
"wildcard": "^1.1.0"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
@@ -2205,6 +2606,11 @@
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/namespace-emitter": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g=="
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
@@ -2222,6 +2628,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -2380,6 +2791,31 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2585,6 +3021,14 @@
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
@@ -2596,6 +3040,35 @@
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/slate": {
"version": "0.72.8",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
"dependencies": {
"immer": "^9.0.6",
"is-plain-object": "^5.0.0",
"tiny-warning": "^1.0.3"
}
},
"node_modules/slate-history": {
"version": "0.66.0",
"resolved": "https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz",
"integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/snabbdom": {
"version": "3.6.4",
"resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.4.tgz",
"integrity": "sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==",
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2604,6 +3077,11 @@
"node": ">=0.10.0"
}
},
"node_modules/ssr-window": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
@@ -2652,6 +3130,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2664,6 +3147,11 @@
"node": ">=8.0"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
@@ -3013,6 +3501,11 @@
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+2
View File
@@ -9,6 +9,8 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pinia": "^2.1.0",
+10
View File
@@ -18,4 +18,14 @@ export function getCaptcha() {
export function getRouters() {
return request({ url: '/getRouters', method: 'get' })
}
// 短信登录 - 发送验证码 (手机号必须已注册)
export function sendLoginSms(phone) {
return request({ url: '/business/auth/smsSendCode', method: 'post', data: { phone } })
}
// 短信登录 - 校验验证码并登录, 返回 res.data 含 token
export function smsLogin(data) {
return request({ url: '/business/auth/smsLogin', method: 'post', data })
}
+152
View File
@@ -0,0 +1,152 @@
<!--
通用 OSS 文件上传控件 (支持 PDF / 图片 / 自定义 accept)
OssImageUploader 不同: 这里不强制要求图片类型, 显示上传的文件名/图标
用法:
<oss-file-uploader
v-model="form.fileUrl"
:dir="'ry8080/special-plan/'"
accept=".pdf,.png,.jpg,.jpeg"
placeholder="点击上传文件"
/>
-->
<template>
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly }" @click="handleClick">
<div v-if="!modelValue && !readonly" class="ht-file-placeholder">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
<div v-else-if="modelValue" class="ht-file-info">
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<a class="file-name" :href="modelValue" target="_blank" @click.stop>{{ fileName }}</a>
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
</div>
<el-button v-if="!readonly" link type="danger" size="small" class="remove-btn" @click.stop="onRemove">移除</el-button>
</div>
</div>
<input
type="file"
ref="fileInput"
:accept="accept"
style="display:none"
@change="onFileChange"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { uploadToOss } from '@/utils/oss'
const props = defineProps({
modelValue: { type: String, default: '' },
dir: { type: String, default: 'ry8080/file/' },
placeholder: { type: String, default: '点击上传文件' },
hint: { type: String, default: '' },
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
maxSize: { type: Number, default: 10 }, // MB
block: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const fileInput = ref(null)
const uploading = ref(false)
const fileName = computed(() => {
if (!props.modelValue) return ''
// 取 URL 最后一段, 去 query 参数
const url = props.modelValue.split('?')[0]
return url.substring(url.lastIndexOf('/') + 1)
})
const ext = computed(() => {
const n = fileName.value
const i = n.lastIndexOf('.')
return i >= 0 ? n.substring(i + 1) : ''
})
const fileSizeText = computed(() => '已上传')
function handleClick() {
if (props.readonly || uploading.value) return
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
// 类型校验 (按 accept 列表)
const allowed = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (allowed.length) {
const lower = file.name.toLowerCase()
const ok = allowed.some(rule => {
if (rule.startsWith('.')) return lower.endsWith(rule)
if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))
return file.type === rule
})
if (!ok) return ElMessage.warning('文件类型不符, 允许: ' + props.accept)
}
if (file.size / 1024 / 1024 > props.maxSize) {
return ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
}
uploading.value = true
try {
const url = await uploadToOss(file, props.dir)
emit('update:modelValue', url)
ElMessage.success('上传成功')
} catch (err) {
ElMessage.error(err.message || '上传失败')
} finally {
uploading.value = false
}
}
function onRemove() {
emit('update:modelValue', '')
}
</script>
<style scoped>
.ht-file-upload {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
background: #fafafa;
cursor: pointer;
padding: 12px 14px;
transition: border-color 0.2s;
min-height: 60px;
}
.ht-file-upload:hover { border-color: #1890ff; }
.ht-file-upload.has-file { border-style: solid; border-color: #52c41a; background: #fff; }
.ht-file-upload.is-block { width: 100%; }
.ht-file-upload.readonly { cursor: default; }
.ht-file-placeholder {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px;
}
.placeholder-text { font-size: 14px; color: #8c8c8c; }
.placeholder-hint { font-size: 12px; color: #c0c4cc; }
.ht-file-info {
display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0;
}
.file-icon { color: #1890ff; flex-shrink: 0; }
.file-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.file-name {
font-size: 14px; color: #1a1a1a; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
.file-name:hover { color: #1890ff; text-decoration: underline; }
.file-type { font-size: 12px; color: #909399; }
.remove-btn { flex-shrink: 0; }
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="rich-editor-wrap">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
:mode="mode"
class="rich-editor-toolbar"
/>
<Editor
v-model="editorValue"
:default-config="editorConfig"
:mode="mode"
style="height: 400px; overflow-y: hidden;"
@on-created="onCreated"
@on-change="handleChange"
/>
</div>
</template>
<script setup>
import { ref, shallowRef, computed, onBeforeUnmount } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '请输入内容...' },
mode: { type: String, default: 'default' } // 'default' | 'simple'
})
const emit = defineEmits(['update:modelValue', 'change'])
// prop 是只读的, 用 computed 中转
const editorValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const editorRef = shallowRef(null)
const toolbarConfig = {
excludeKeys: [
'group-video', // 视频上传, 避免服务器无视频上传接口
'insertVideo',
'uploadVideo'
]
}
const editorConfig = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
// 简化: 不接图片上传接口, 用户直接粘贴/拖拽本地图片由浏览器转 base64
customUpload: () => {}
}
}
}
function onCreated(editor) {
editorRef.value = editor
}
function handleChange(editor) {
emit('change', editor.getHtml())
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) editor.destroy()
})
</script>
<style scoped>
.rich-editor-wrap {
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
}
.rich-editor-toolbar {
border-bottom: 1px solid #dcdfe6;
}
</style>
+62 -12
View File
@@ -6,10 +6,24 @@
<div class="logo-sub">BAHIM 工作台</div>
</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item v-for="m in menu" :key="m.path" :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</el-menu-item>
<template v-for="m in menu" :key="m.path">
<!-- 二级菜单: children 的用 el-sub-menu -->
<el-sub-menu v-if="m.children" :index="m.path">
<template #title>
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</template>
<el-menu-item v-for="c in m.children" :key="c.path" :index="c.path">
<el-icon><component :is="c.icon" /></el-icon>
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 一级菜单 -->
<el-menu-item v-else :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
@@ -41,7 +55,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection } from '@element-plus/icons-vue'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -58,11 +72,17 @@ const MENU = {
{ path: '/admin/roles', title: '角色管理', icon: Setting },
{ path: '/admin/projects', title: '项目全览', icon: Document },
{ path: '/admin/meetings', title: '会议全览', icon: Calendar },
{ path: '/admin/department', title: '科室管理', icon: Folder },
{ path: '/admin/title', title: '职称管理', icon: Medal },
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Connection },
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/admin/library', title: '资料库管理', icon: Box, children: [
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Star },
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/admin/department', title: '科室管理', icon: Grid },
{ path: '/admin/title', title: '职称管理', icon: Medal }
]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files },
{ path: '/admin/special-plan', title: '七大专项计划', icon: Compass }
]},
{ path: '/admin/account', title: '账号信息', icon: User }
],
leader: [
@@ -124,8 +144,38 @@ const onCommand = (cmd) => { if (cmd === 'logout') logout() }
.logo-text { color: #fff; font-size: 16px; font-weight: 600; }
.logo-sub { color: #8a99b3; font-size: 12px; }
.aside-menu { background: #001529; border: none; }
:deep(.el-menu-item) { color: #c0c4cc; }
:deep(.el-menu-item.is-active) { background: #1890ff; color: #fff; }
/* 一级菜单 + 二级父标题: 统一默认/hover/active 样式 */
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: #c0c4cc;
}
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: #1f3a5f;
color: #fff;
}
/* 子项激活: 蓝底白字 */
:deep(.el-menu-item.is-active) {
background: #1890ff;
color: #fff;
}
/* 父标题在子项激活时高亮 (Element Plus 自动给 el-sub-menu 加 is-active) */
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: #fff;
}
/* 二级菜单容器: 跟随侧边栏深蓝底, 不要 Element Plus 默认白底 */
:deep(.el-menu--inline),
:deep(.el-menu--inline .el-menu-item) {
background-color: #001529;
}
:deep(.el-menu--inline .el-menu-item:hover) {
background-color: #1f3a5f;
}
:deep(.el-menu--inline .el-menu-item.is-active) {
background: #1890ff;
}
.topbar { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #ebeef5; }
.topbar-left { display: flex; align-items: center; }
.page-title { font-size: 16px; font-weight: 600; }
+7 -1
View File
@@ -13,7 +13,9 @@ const routes = [
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '赞助方注册' } },
{ path: 'login', name: 'login', component: () => import('@/views/auth/Login.vue'), meta: { title: '登录' } }
{ path: 'login', name: 'login', component: () => import('@/views/auth/Login.vue'), meta: { title: '登录' } },
{ path: 'article/:type', name: 'portal-article', component: () => import('@/views/portal/ArticleView.vue'), meta: { title: '协议' } },
{ path: 'special-plan/:id', name: 'portal-special-plan', component: () => import('@/views/portal/SpecialPlanDetail.vue'), meta: { title: '专项计划详情' } }
]
},
// 各角色后台(登录后)
@@ -40,6 +42,10 @@ const routes = [
{ path: 'executor-people/new', name: 'admin-executor-people-new', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'admin-executor-people-edit', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'orgs', name: 'admin-orgs', component: () => import('@/views/admin/Orgs.vue'), meta: { title: '公司管理' } },
{ path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } },
{ path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } },
{ path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '七大专项计划' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
]
},
+41
View File
@@ -0,0 +1,41 @@
import { ref } from 'vue'
/**
* 异步操作防重入锁 (UI 层面的"防抖")
*
* 适用场景: 接口响应慢, 用户在等待期间可重复点击同一按钮, 导致并发请求.
* - 第一次调用 run(): locked 置 true, 执行 fn, 完成后 (无论成败) 解锁
* - 锁定期间的调用: 立即 return, 不发起任何请求
*
* 用法:
* const { locked, run } = useAsyncLock()
*
* async function sendSms() {
* if (!valid) return
* await run(async () => {
* const r = await request(...)
* // ... 业务处理 ...
* })
* }
*
* 模板绑定:
* <el-button :loading="locked" :disabled="locked || ..." @click="sendSms">发送验证码</el-button>
*
* 注意: 这是"按钮重入锁", 不是传统 lodash.debounce (后者会延迟执行). 这里
* 第一次点击立即发出, 但锁定期间所有点击被丢弃 — 这才是"防重复请求"的正确语义.
*/
export function useAsyncLock() {
const locked = ref(false)
async function run(fn) {
if (locked.value) return undefined
locked.value = true
try {
return await fn()
} finally {
locked.value = false
}
}
return { locked, run }
}
-9
View File
@@ -2,15 +2,6 @@
<div class="page-card admin-account">
<div class="breadcrumb">首页 / 账号信息</div>
<el-descriptions :column="2" border style="margin-bottom: 24px">
<el-descriptions-item label="账号">{{ profile.userName }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ profile.nickName || profile.userName }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ roleLabel }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ profile.phonenumber || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ profile.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ profile.loginDate || '-' }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="tab">
<el-tab-pane label="修改资料" name="profile">
<el-form :model="form" label-width="100px" class="account-form">
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="page-card admin-article">
<div class="breadcrumb">首页 / 协议管理</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="类型">
<el-select v-model="q.type" placeholder="全部" clearable style="width:160px">
<el-option label="用户协议" value="agreement" />
<el-option label="隐私政策" value="privacy" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="q.title" placeholder="输入标题" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ TYPE_LABEL[row.type] || row.type }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="$router.push('/admin/article/edit/' + row.id)">编辑</el-button>
<el-button size="small" link type="primary" :disabled="row.status !== '0'" @click="preview(row)" target="_blank">预览</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'
const TYPE_LABEL = { agreement: '用户协议', privacy: '隐私政策' }
const q = reactive({ type: '', title: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/article/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.type = ''; q.title = ''; q.status = ''
q.pageNum = 1
load()
}
function preview(row) {
// 启用的文章才允许预览(实际注册页只看启用的), 这里做软拦截
window.open(`/#/article/${row.type}`, '_blank')
}
onMounted(load)
</script>
<style scoped>
.admin-article { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div class="page-card biz-article-edit">
<div class="header">
<el-button :icon="ArrowLeft" @click="$router.back()">返回</el-button>
<span class="title">{{ isEdit ? '编辑文章' : '新建文章' }}</span>
</div>
<el-form :model="form" label-width="100px" v-loading="loading">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" maxlength="200" />
</el-form-item>
<el-form-item label="类型" required>
<el-select v-model="form.type" style="width:200px" :disabled="isEdit">
<el-option label="用户协议" value="agreement" />
<el-option label="隐私政策" value="privacy" />
</el-select>
<span class="form-tip" v-if="isEdit">类型不可修改</span>
</el-form-item>
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="正文" required>
<RichEditor v-model="form.content" placeholder="请输入正文, 支持富文本格式" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选, 内部备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import RichEditor from '@/components/RichEditor.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(false)
const saving = ref(false)
const form = reactive({
id: null,
title: '',
type: 'agreement',
content: '',
status: '0',
remark: ''
})
async function loadDetail() {
if (!isEdit.value) return
loading.value = true
try {
const r = await request({ url: `/business/article/${route.params.id}`, method: 'get' })
const data = (r.data && r.data.data) || r.data || {}
Object.assign(form, data)
} catch (e) {
ElMessage.error('加载失败')
} finally { loading.value = false }
}
async function save() {
if (!form.title.trim()) return ElMessage.warning('请输入标题')
if (!form.content.trim()) return ElMessage.warning('请输入正文')
saving.value = true
try {
if (isEdit.value) {
await request({ url: '/business/article', method: 'put', data: { ...form } })
ElMessage.success('保存成功')
} else {
await request({ url: '/business/article', method: 'post', data: { ...form } })
ElMessage.success('新建成功')
}
router.push('/admin/article')
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { saving.value = false }
}
onMounted(loadDetail)
</script>
<style scoped>
.biz-article-edit { padding: 16px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.header .title { font-size: 18px; font-weight: 600; }
.form-tip { margin-left: 12px; font-size: 12px; color: #909399; }
:deep(.w-e-text-container) { background: #fff; }
</style>
@@ -0,0 +1,102 @@
<template>
<div class="page-card admin-plan">
<div class="breadcrumb">首页 / 网站管理 / 七大专项计划</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="标题">
<el-input v-model="q.title" placeholder="输入标题" clearable />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="q.contentType" placeholder="全部" clearable style="width:140px">
<el-option label="富文本" value="rich" />
<el-option label="上传文件" value="file" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="220" />
<el-table-column label="类型" width="110">
<template #default="{ row }">{{ TYPE_LABEL[row.contentType] || row.contentType }}</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" sortable />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="$router.push('/admin/special-plan/edit/' + row.id)">编辑</el-button>
<el-button size="small" link type="primary" :disabled="row.status !== '0'" @click="preview(row)">预览</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import request from '@/utils/request'
const TYPE_LABEL = { rich: '富文本', file: '上传文件' }
const q = reactive({ title: '', contentType: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/specialPlan/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.title = ''; q.contentType = ''; q.status = ''
q.pageNum = 1
load()
}
function preview(row) {
window.open(`/#/special-plan/${row.id}`, '_blank')
}
onMounted(load)
</script>
<style scoped>
.admin-plan { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
@@ -0,0 +1,124 @@
<template>
<div class="page-card biz-plan-edit">
<div class="header">
<el-button :icon="ArrowLeft" @click="$router.back()">返回</el-button>
<span class="title">{{ isEdit ? '编辑专项计划' : '新建专项计划' }}</span>
</div>
<el-form :model="form" label-width="100px" v-loading="loading">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入专项计划名称" maxlength="200" />
</el-form-item>
<el-form-item label="内容类型" required>
<el-radio-group v-model="form.contentType" @change="onTypeChange">
<el-radio value="rich">富文本</el-radio>
<el-radio value="file">上传文件 (PDF / PNG)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="正文" :required="form.contentType === 'rich'" v-if="form.contentType === 'rich'">
<RichEditor v-model="form.content" placeholder="请输入专项计划正文" />
</el-form-item>
<el-form-item label="文件" :required="form.contentType === 'file'" v-else>
<OssFileUploader v-model="form.fileUrl" dir="ry8080/special-plan/" accept=".pdf,.png" placeholder="点击上传 PDF / PNG" hint="仅支持 PDF 或 PNG, ≤10MB" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
<span class="form-tip">数字越小越靠前</span>
</el-form-item>
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选, 内部备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import RichEditor from '@/components/RichEditor.vue'
import OssFileUploader from '@/components/OssFileUploader.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(false)
const saving = ref(false)
const form = reactive({
id: null,
title: '',
contentType: 'rich',
content: '',
fileUrl: '',
sortOrder: 0,
status: '0',
remark: ''
})
async function loadDetail() {
if (!isEdit.value) return
loading.value = true
try {
const r = await request({ url: `/business/specialPlan/${route.params.id}`, method: 'get' })
const data = (r.data && r.data.data) || r.data || {}
Object.assign(form, data)
} catch (e) {
ElMessage.error('加载失败')
} finally { loading.value = false }
}
function onTypeChange(val) {
// 切换内容类型时清空另一字段, 避免脏数据
if (val === 'rich') form.fileUrl = ''
else form.content = ''
}
async function save() {
if (!form.title.trim()) return ElMessage.warning('请输入标题')
if (form.contentType === 'rich' && !form.content.trim()) return ElMessage.warning('请输入正文')
if (form.contentType === 'file' && !form.fileUrl) return ElMessage.warning('请上传文件')
saving.value = true
try {
if (isEdit.value) {
await request({ url: '/business/specialPlan', method: 'put', data: { ...form } })
ElMessage.success('保存成功')
} else {
await request({ url: '/business/specialPlan', method: 'post', data: { ...form } })
ElMessage.success('新建成功')
}
router.push('/admin/special-plan')
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { saving.value = false }
}
onMounted(loadDetail)
</script>
<style scoped>
.biz-plan-edit { padding: 16px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.header .title { font-size: 18px; font-weight: 600; }
.form-tip { margin-left: 12px; font-size: 12px; color: #909399; }
</style>
+335 -45
View File
@@ -30,10 +30,15 @@
<!-- 右侧登录卡片 -->
<section class="login-section">
<div class="login-card">
<h1 class="login-title">账号登录</h1>
<p class="login-subtitle">请输入您的账号信息</p>
<h1 class="login-title">{{ loginMode === 'password' ? '账号登录' : '手机号登录' }}</h1>
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<form id="loginForm" @submit.prevent="onSubmit">
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
@@ -65,6 +70,29 @@
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
<form v-else id="smsLoginForm" @submit.prevent="onSmsLoginSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="smsForm.phone" type="text" class="form-input" placeholder="请输入手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="smsForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="smsSmsLocked || smsSmsCountdown > 0 || !smsForm.phone" @click="sendLoginSmsCode">
{{ smsSmsLocked ? '发送中...' : smsSmsCountdown > 0 ? `${smsSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<button type="submit" class="login-btn" :disabled="smsLoginLocked">{{ smsLoginLocked ? '登录中...' : '登 录' }}</button>
<div class="form-bottom-links">
<a class="bottom-link" @click="onRegister">新用户注册</a>
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
</div>
</section>
</main>
@@ -94,15 +122,68 @@
</div>
</div>
</div>
<!-- 忘记密码弹窗 -->
<div class="modal-overlay" :class="{ active: showForgotModal }" @click.self="closeForgot">
<div class="modal">
<h2 class="modal-title">忘记密码</h2>
<p class="modal-subtitle">通过注册时填写的手机号验证后重置密码</p>
<form @submit.prevent="onResetSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="forgotForm.phone" type="text" class="form-input" placeholder="请输入注册时的手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="forgotForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="forgotSmsLocked || forgotSmsCountdown > 0 || !forgotForm.phone" @click="sendForgotSms">
{{ forgotSmsLocked ? '发送中...' : forgotSmsCountdown > 0 ? `${forgotSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.password" type="password" class="form-input" placeholder="新密码 (6-20 位)" autocomplete="new-password">
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.confirmPassword" type="password" class="form-input" placeholder="确认新密码" autocomplete="new-password">
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeForgot">取消</button>
<button class="modal-btn primary" type="submit" :disabled="forgotLoading">{{ forgotLoading ? '提交中...' : '重置密码' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login, getInfo, getCaptcha } from '@/api/auth'
import request from '@/utils/request'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
import { useUserStore } from '@/store/user'
import { useAsyncLock } from '@/utils/useAsyncLock'
// 图形验证码刷新 + 忘记密码短信 + 短信登录 各自独立锁 (避免重复请求)
const { locked: captchaLocked, run: runCaptchaOnce } = useAsyncLock()
const { locked: forgotSmsLocked, run: runForgotSmsOnce } = useAsyncLock()
const { locked: smsSmsLocked, run: runSmsOnce } = useAsyncLock()
const { locked: smsLoginLocked, run: runSmsLoginOnce } = useAsyncLock()
const router = useRouter()
const userStore = useUserStore()
@@ -114,6 +195,12 @@ const loading = ref(false)
const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('password')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
const registerTypes = [
{ value: 'expert', name: '项目参与专家', desc: '负责项目方案设计与评审' },
{ value: 'executor', name: '项目执行单位(供应商)', desc: '承担项目执行,需走入库流程' },
@@ -130,16 +217,18 @@ const userTypes = [
]
async function loadCaptcha() {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
await runCaptchaOnce(async () => {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
}
} catch (e) {
captchaImg.value = ''
}
} catch (e) {
captchaImg.value = ''
}
})
}
async function onSubmit() {
@@ -154,36 +243,7 @@ async function onSubmit() {
loading.value = true
try {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
// username 推断仅作兜底, 真实 role 以 /getInfo 返回的 sys_user.role_type 为准
const fallbackRole = autoRole(form.username)
userStore.setToken(res.token)
// /login 接口只返回 token; 调 /getInfo 拿完整 user (含 accountType/parentUserId/roleType, 让 isMain/isSub/role 都从后端真实值取)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || form.username,
nickName: u.nickName || form.username,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: res.userId,
userName: form.username,
nickName: form.username,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${form.username}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
await afterLogin(res.token, form.username, autoRole(form.username))
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
loadCaptcha()
@@ -192,6 +252,97 @@ async function onSubmit() {
}
}
/**
* 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页
* 密码登录和短信登录共用
*/
async function afterLogin(token, displayName, fallbackRole) {
userStore.setToken(token)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: null,
userName: displayName,
nickName: displayName,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
}
function switchMode(mode) {
if (loginMode.value === mode) return
loginMode.value = mode
// 切换时清空两条表单, 避免脏数据
if (mode === 'password') {
smsForm.phone = ''
smsForm.smsCode = ''
smsForm.uuid = ''
if (smsSmsTimer) { clearInterval(smsSmsTimer); smsSmsTimer = null }
smsSmsCountdown.value = 0
} else {
form.username = ''
form.password = ''
form.code = ''
// 切到短信登录时不需要图形验证码, 不再 loadCaptcha
}
}
async function sendLoginSmsCode() {
if (!/^1\d{10}$/.test(smsForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runSmsOnce(async () => {
try {
const r = await sendLoginSms(smsForm.phone)
smsForm.uuid = r.data || ''
ElMessage.success('验证码已发送')
smsSmsCountdown.value = 60
smsSmsTimer = setInterval(() => {
smsSmsCountdown.value--
if (smsSmsCountdown.value <= 0) {
clearInterval(smsSmsTimer)
smsSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 已弹错误, 这里只重置 uuid
smsForm.uuid = ''
}
})
}
async function onSmsLoginSubmit() {
if (!/^1\d{10}$/.test(smsForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!smsForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!smsForm.uuid) return ElMessage.warning('请先获取验证码')
await runSmsLoginOnce(async () => {
try {
const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid })
// 短信登录后无 username, 用 phone 作为显示名; role 兜底默认 leader
await afterLogin(res.token, smsForm.phone, 'leader')
} catch (e) {
// request.js 已弹错误
}
})
}
function autoRole(username) {
const u = (username || '').toLowerCase()
if (u === 'admin' || u === 'ry') return 'admin'
@@ -241,10 +392,88 @@ function confirmRegister() {
}
function onForgot() {
ElMessage.info('请联系系统管理员重置密码')
showForgotModal.value = true
}
// ===== 忘记密码弹窗 =====
const showForgotModal = ref(false)
const forgotLoading = ref(false)
const forgotSmsCountdown = ref(0)
let forgotSmsTimer = null
const forgotForm = reactive({ phone: '', smsCode: '', password: '', confirmPassword: '' })
let forgotSmsUuid = ''
function closeForgot() {
showForgotModal.value = false
forgotForm.phone = ''
forgotForm.smsCode = ''
forgotForm.password = ''
forgotForm.confirmPassword = ''
forgotSmsUuid = ''
if (forgotSmsTimer) { clearInterval(forgotSmsTimer); forgotSmsTimer = null }
forgotSmsCountdown.value = 0
}
async function sendForgotSms() {
if (!/^1\d{10}$/.test(forgotForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runForgotSmsOnce(async () => {
try {
const r = await request({ url: '/business/auth/forgotSendSms', method: 'post', data: { phone: forgotForm.phone } })
forgotSmsUuid = r.data || ''
ElMessage.success('验证码已发送')
forgotSmsCountdown.value = 60
forgotSmsTimer = setInterval(() => {
forgotSmsCountdown.value--
if (forgotSmsCountdown.value <= 0) {
clearInterval(forgotSmsTimer)
forgotSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 拦截器已弹 ElMessage.error, 这里只重置
forgotSmsUuid = ''
}
})
}
async function onResetSubmit() {
if (!/^1\d{10}$/.test(forgotForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!forgotForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!forgotForm.password || forgotForm.password.length < 6 || forgotForm.password.length > 20) {
return ElMessage.warning('密码长度 6-20 位')
}
if (forgotForm.password !== forgotForm.confirmPassword) {
return ElMessage.warning('两次密码输入不一致')
}
forgotLoading.value = true
try {
await request({
url: '/business/auth/resetPassword',
method: 'post',
data: {
phone: forgotForm.phone,
smsCode: forgotForm.smsCode,
uuid: forgotSmsUuid,
password: forgotForm.password,
confirmPassword: forgotForm.confirmPassword
}
})
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// request.js 已弹错误
} finally {
forgotLoading.value = false
}
}
onMounted(() => { loadCaptcha() })
onUnmounted(() => {
if (forgotSmsTimer) clearInterval(forgotSmsTimer)
if (smsSmsTimer) clearInterval(smsSmsTimer)
})
</script>
<style>
@@ -396,6 +625,30 @@ onMounted(() => { loadCaptcha() })
margin-bottom: 28px;
}
/* 登录模式切换 tabs */
.login-page .login-tabs{
display: flex;
gap: 24px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.login-page .login-tabs .tab{
padding: 8px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
user-select: none;
}
.login-page .login-tabs .tab:hover{ color: #1e3a8a; }
.login-page .login-tabs .tab.active{
color: #1e3a8a;
border-bottom-color: #1e3a8a;
font-weight: 500;
}
.login-page .form-group{
margin-bottom: 18px;
position: relative;
@@ -689,4 +942,41 @@ onMounted(() => { loadCaptcha() })
.modal-btn.secondary:hover { background: #ebeef5; }
.modal-btn.primary { background: var(--brand-primary, #1890ff); color: #fff; }
.modal-btn.primary:hover { background: var(--brand-primary-deep, #096dd9); }
/* ===== 忘记密码弹窗 ===== */
.modal-subtitle {
font-size: 13px;
color: #6b7280;
text-align: center;
margin: -12px 0 20px;
}
.sms-group {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.sms-group .sms-input {
flex: 1;
padding-left: 14px;
}
.sms-btn {
flex-shrink: 0;
width: 110px;
height: 44px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #1e3a8a;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.sms-btn:hover:not(:disabled) {
border-color: #1e3a8a;
background: #f3f4f6;
}
.sms-btn:disabled {
color: #9ca3af;
background: #f3f4f6;
cursor: not-allowed;
}
</style>
+31 -33
View File
@@ -38,8 +38,8 @@
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" />
<el-button class="sms-btn" :disabled="smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -53,7 +53,11 @@
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">平台协议</a></el-checkbox>
<el-checkbox v-model="agreed">我已阅读并同意
<a href="#/article/agreement" target="_blank">用户协议</a>
<a href="#/article/privacy" target="_blank">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
@@ -78,16 +82,6 @@
已有账号? <a href="javascript:void(0)" @click="$router.push('/login')">立即登录</a>
</div>
</div>
<!-- 平台协议弹窗 -->
<el-dialog v-model="showAgreement" title="平台协议" width="560px">
<div class="agreement-content">
<p>1. 本平台仅提供项目撮合与流程管理服务, 不参与任何具体项目的执行.</p>
<p>2. 注册企业需提供真实有效的资质材料, 平台有权随时核查.</p>
<p>3. 平台对企业的商业信息负有保密义务.</p>
<p>4. 注册即表示同意上述条款.</p>
</div>
</el-dialog>
</div>
</template>
@@ -96,12 +90,14 @@ import { reactive, ref, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { registerExecutor } from '@/api/public'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: smsLocked, run: sendSmsOnce } = useAsyncLock()
const step = ref(0)
const formRef = ref()
const submitting = ref(false)
const agreed = ref(false)
const showAgreement = ref(false)
const form = reactive({
username: '',
@@ -150,25 +146,27 @@ async function sendSms() {
if (!/^1\d{10}$/.test(form.phone)) {
return ElMessage.warning('请先输入正确的手机号')
}
try {
const resp = await request({
url: '/business/auth/registerSendSms',
method: 'post',
data: { phone: form.phone }
})
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
await sendSmsOnce(async () => {
try {
const resp = await request({
url: '/business/auth/registerSendSms',
method: 'post',
data: { phone: form.phone }
})
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
})
}
// ===== 提交 =====
+27 -29
View File
@@ -36,8 +36,8 @@
<el-form-item label="短信验证码" prop="code">
<div class="sms-row">
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" />
<el-button :disabled="smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
<el-button :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -55,7 +55,11 @@
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">隐私保护条款及授权书</a></el-checkbox>
<el-checkbox v-model="agreed">我已阅读并同意
<a href="#/article/agreement" target="_blank">用户协议</a>
<a href="#/article/privacy" target="_blank">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
@@ -72,16 +76,6 @@
</el-result>
</div>
</div>
<!-- 隐私协议弹窗 -->
<el-dialog v-model="showAgreement" title="隐私保护条款及授权书" width="600px">
<div class="agreement-content">
<p>1. 我们仅在为您提供专家注册项目评审等服务的过程中,收集您的姓名手机号工作单位职称等必要信息</p>
<p>2. 您的个人信息将严格保密,仅用于项目流程内部使用,不会向任何第三方披露</p>
<p>3. 您有权随时查看更正删除您的个人信息</p>
<p>4. 注册即表示您同意上述条款</p>
</div>
</el-dialog>
</div>
</template>
@@ -94,6 +88,9 @@ import request from '@/utils/request'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: smsLocked, run: sendSmsOnce } = useAsyncLock()
const router = useRouter()
const step = ref(0)
@@ -139,7 +136,6 @@ const rules = {
}
const agreed = ref(false)
const showAgreement = ref(false)
const smsCountdown = ref(0)
let smsTimer = null
@@ -148,21 +144,23 @@ async function sendCode() {
if (!/^1\d{10}$/.test(form.phone)) {
return ElMessage.warning('请先输入正确的手机号')
}
try {
const resp = await request({ url: '/business/auth/registerSendSms', method: 'post', data: { phone: form.phone } })
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
await sendSmsOnce(async () => {
try {
const resp = await request({ url: '/business/auth/registerSendSms', method: 'post', data: { phone: form.phone } })
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
})
}
async function onSubmit() {
+8 -2
View File
@@ -10,7 +10,7 @@
<el-form :model="form" label-width="100px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" /></el-form-item></el-form>
</template>
<template v-else>
<el-form :model="form" label-width="100px"><el-form-item label="单位名称"><el-input v-model="form.unitName" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contact" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item><el-form-item label="赞助意向"><el-input v-model="form.intent" type="textarea" /></el-form-item></el-form>
<el-form :model="form" label-width="100px"><el-form-item label="单位名称"><el-input v-model="form.unitName" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contact" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item><el-form-item label="赞助意向"><el-input v-model="form.intent" type="textarea" /></el-form-item><el-form-item><el-checkbox v-model="agreed">我已阅读并同意 <a href="#/article/agreement" target="_blank">《用户协议》</a> 和 <a href="#/article/privacy" target="_blank">隐私政策</a></el-checkbox></el-form-item></el-form>
</template>
</div>
<div style="text-align:center;margin-top:16px">
@@ -27,9 +27,15 @@ import { registerSponsor } from '@/api/public'
import { ElMessage } from 'element-plus'
const step = ref(0)
const form = reactive({ username: '', password: '', unitName: '', contact: '', phone: '', intent: '' })
const agreed = ref(false)
const prev = () => step.value = Math.max(0, step.value - 1)
const next = async () => {
if (step.value === 1) { try { await registerSponsor(form); ElMessage.success('已提交') } catch { /* demo */ } step.value = 2; return }
if (step.value === 1) {
if (!agreed.value) return ElMessage.warning('请先阅读并同意用户协议和隐私政策')
try { await registerSponsor(form); ElMessage.success('已提交') } catch { /* demo */ }
step.value = 2
return
}
step.value++
}
</script>
+26 -21
View File
@@ -147,8 +147,8 @@
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
<el-button class="btn-sms" :disabled="phoneSmsLocked || phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsLocked ? '发送中...' : phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
@@ -174,6 +174,9 @@ import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: phoneSmsLocked, run: sendPhoneSmsOnce } = useAsyncLock()
const store = useUserStore()
const tab = ref('profile')
@@ -409,25 +412,27 @@ async function sendSms() {
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
await sendPhoneSmsOnce(async () => {
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
})
}
async function onChangePhone() {
+1 -1
View File
@@ -45,7 +45,7 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
+1 -1
View File
@@ -124,7 +124,7 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions }}/{{ detail.totalPeriods }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
@@ -29,7 +29,7 @@
<span class="info-value">{{ fmtDate(row.endTime) }}</span>
</el-form-item>
<el-form-item label="赞助公司">
<span class="info-value">{{ row.orgName || '-' }}</span>
<span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span>
</el-form-item>
<el-form-item label="项目形式">
<span class="info-value">{{ row.projectForm || '-' }}</span>
+99 -41
View File
@@ -3,18 +3,13 @@
<div class="breadcrumb">首页 / 项目管理</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="赞助公司:"><el-input v-model="q.orgName" placeholder="输入赞助公司" clearable /></el-form-item>
<el-form-item label="公司类型:">
<el-select v-model="q.orgType" placeholder="全部" clearable style="width:120px">
<el-option label="赞助方" value="sponsor" />
<el-option label="执行方" value="executor" />
</el-select>
</el-form-item>
<el-form-item label="赞助方负责人:"><el-input v-model="q.sponsorAdminUserName" placeholder="输入账号/昵称" clearable /></el-form-item>
<el-form-item label="服务机构:"><el-input v-model="q.execOrgName" placeholder="输入服务机构" clearable /></el-form-item>
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称:"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="项目形式:">
<el-select v-model="q.projectForm" placeholder="请选择" clearable>
<el-option label="线上" value="线上" /><el-option label="线下" value="线" />
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
<el-option label="线上" value="线上" /><el-option label="线下" value="线" />
<el-option label="线上+线下" value="线上+线下" /><el-option label="其他" value="其他" />
</el-select>
</el-form-item>
@@ -24,12 +19,12 @@
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="是否结题:">
<el-select v-model="q.isFinished" placeholder="请选择" clearable>
<el-select v-model="q.isFinished" placeholder="请选择" clearable style="width:120px">
<el-option label="已结题" value="1" /><el-option label="未结题" value="0" />
</el-select>
</el-form-item>
<el-form-item label="是否结算:">
<el-select v-model="q.isSettled" placeholder="请选择" clearable>
<el-select v-model="q.isSettled" placeholder="请选择" clearable style="width:120px">
<el-option label="已结算" value="1" /><el-option label="未结算" value="0" />
</el-select>
</el-form-item>
@@ -69,14 +64,7 @@
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="orgName" label="赞助公司" width="160" show-overflow-tooltip />
<el-table-column prop="orgType" label="公司类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
{{ row.orgType === 'sponsor' ? '赞助方' : '执行方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sponsorAdminUserName" label="赞助方负责人" width="160" show-overflow-tooltip />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
<template #default="{ row }">
@@ -133,11 +121,26 @@
<div style="color:#606266;font-size:13px"> <b>{{ assignBatchProjects.length }}</b> 个项目</div>
</el-form-item>
<el-form-item label="支持方">
<el-select v-model="assignForm.orgId" placeholder="请选择赞助公司" filterable :filter-method="searchSupporters" clearable style="width:100%" @change="onSupporterPick">
<el-select v-model="assignForm.sponsorAdminUserId" placeholder="请选择赞助方负责人" filterable :filter-method="searchSupporters" clearable style="width:100%" @change="onSupporterPick">
<el-option v-for="u in supporterOptions" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
</el-select>
</el-form-item>
<el-divider>执行方分配</el-divider>
<!-- 单选模式: 显示项目总场次 / 总金额() vs 已分配 (实时) -->
<div v-if="!assignBatchMode" class="assign-sessions-bar">
<span>项目总场次: <b>{{ assignForm.totalSessions || 0 }}</b></span>
<span :class="{ 'is-over': singleSessionOver }">已分配场次: <b>{{ assignedSessions }}</b></span>
<span v-if="singleSessionOver" class="over-warn"> 已超出 {{ assignedSessions - assignForm.totalSessions }} </span>
<span class="sep">|</span>
<span>总金额(): <b>{{ assignForm.totalAmount || 0 }}</b></span>
<span :class="{ 'is-over': singleAmountOver }">已分配金额: <b>¥ {{ assignedAmount }}</b></span>
<span v-if="singleAmountOver" class="over-warn"> 已超出 ¥ {{ Math.round((assignedAmount - assignForm.totalAmount) * 100) / 100 }}</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身总场次 / 总金额逐项校验 -->
<div v-else class="assign-sessions-bar">
<span>本次分配: <b>{{ assignedSessions }}</b> / <b>¥ {{ assignedAmount }}</b></span>
<span class="hint">(保存时按各项目自身的总场次和总金额逐项校验)</span>
</div>
<el-table :data="assignForm.execRows" border>
<el-table-column type="index" label="#" width="50" />
<el-table-column label="执行方名称" min-width="220">
@@ -264,7 +267,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
import { listExecutor, listSupporters } from '@/api/system'
@@ -275,7 +278,7 @@ import Preview from '@/components/Preview.vue'
const router = useRouter()
const route = useRoute()
const q = ref({ orgName: '', orgType: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', ratingScore: '' })
const q = ref({ sponsorAdminUserName: '', execOrgName: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', ratingScore: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -327,7 +330,7 @@ async function doDelete(row) {
}
}
function reset() { q.value = { orgName:'', orgType:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', ratingScore:'' }; page.pageNum = 1; load() }
function reset() { q.value = { sponsorAdminUserName:'', execOrgName:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', ratingScore:'' }; page.pageNum = 1; load() }
// 导出
function exportProjects() { ElMessage.info('导出项目功能开发中') }
@@ -563,20 +566,38 @@ const assignBatchProjects = ref([]) // 批量模式下的项目列表 [
const assignSubmitting = ref(false)
const assignForm = reactive({
projectId: '',
orgId: null,
orgName: '',
sponsorAdminUserId: null,
sponsorAdminUserName: '',
totalSessions: 0, // 项目总场次 (来自项目本身, 不可被分配覆盖)
totalAmount: 0, // 项目总金额(元) (来自项目本身, 不可被分配覆盖)
execRows: [], // 动态加
deadlineDays: 30
})
const supporterOptions = ref([])
let _supporterTimer = null
// 已分配场次合计 (实时)
const assignedSessions = computed(() =>
assignForm.execRows.reduce((s, r) => s + Number(r.sessions || 0), 0)
)
const singleSessionOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalSessions || 0) > 0 && assignedSessions.value > Number(assignForm.totalSessions)
)
// 已分配金额合计 (实时, 保留 2 位小数避免浮点漂移)
const assignedAmount = computed(() => {
const sum = assignForm.execRows.reduce((s, r) => s + Number(r.amount || 0), 0)
return Math.round(sum * 100) / 100
})
const singleAmountOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalAmount || 0) > 0 && assignedAmount.value > Number(assignForm.totalAmount)
)
function makeEmptyExecRow() {
return { _loading: false, _timer: null, _options: [], execUserId: null, execUserName: '', execNickName: '', execOrg: '', sessions: 0, amount: 0, remark: '' }
}
function resetAssignForm() {
Object.assign(assignForm, { projectId:'', orgId:null, orgName:'', execRows:[makeEmptyExecRow()], deadlineDays:30 })
Object.assign(assignForm, { projectId:'', sponsorAdminUserId:null, sponsorAdminUserName:'', totalSessions:0, totalAmount:0, execRows:[makeEmptyExecRow()], deadlineDays:30 })
supporterOptions.value = []
assignBatchMode.value = false
assignBatchProjects.value = []
@@ -592,8 +613,10 @@ function addExecRow() {
function onAssignProjectChange(v) {
const proj = rows.value.find(r => r.projectId === v)
if (proj) {
assignForm.orgId = proj.orgId || null
assignForm.orgName = proj.orgName || ''
assignForm.sponsorAdminUserId = proj.sponsorAdminUserId || null
assignForm.sponsorAdminUserName = proj.sponsorAdminUserName || ''
assignForm.totalSessions = Number(proj.totalSessions || 0)
assignForm.totalAmount = Number(proj.totalAmount || 0)
}
// 切换项目时重新拉已分配记录 (回显)
loadAssigns(v)
@@ -611,9 +634,9 @@ async function loadSupporters(query) {
const r = await listSupporters(params)
supporterOptions.value = r.data || []
// 已选项不在结果里时单独拉取并加首位
if (assignForm.orgId && !supporterOptions.value.find(u => u.userId === assignForm.orgId)) {
const r2 = await listSupporters({ userId: assignForm.orgId })
const sel = (r2.data || []).find(u => u.userId === assignForm.orgId)
if (assignForm.sponsorAdminUserId && !supporterOptions.value.find(u => u.userId === assignForm.sponsorAdminUserId)) {
const r2 = await listSupporters({ userId: assignForm.sponsorAdminUserId })
const sel = (r2.data || []).find(u => u.userId === assignForm.sponsorAdminUserId)
if (sel) supporterOptions.value = [sel, ...supporterOptions.value]
}
} catch (e) {
@@ -627,7 +650,7 @@ function searchSupporters(q) { loadSupporters(q) }
// 选中支持方后回填名称
function onSupporterPick(userId) {
const u = supporterOptions.value.find(x => x.userId === userId)
if (u) assignForm.orgName = u.nickName || u.userName
if (u) assignForm.sponsorAdminUserName = u.nickName || u.userName
}
// 拉取项目已分配的执行方
@@ -698,8 +721,34 @@ async function submitAssign() {
if (!assignBatchMode.value && !assignForm.projectId) return ElMessage.warning('请选择项目')
const valid = assignForm.execRows.filter(r => r.execUserId && Number(r.sessions) > 0)
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
const amount = valid.reduce((s, r) => s + Number(r.amount || 0), 0)
const amount = Math.round(valid.reduce((s, r) => s + Number(r.amount || 0), 0) * 100) / 100
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额
// (项目自身的 total_sessions / total_amount 是创建时定的, 不再被分配覆盖)
if (assignBatchMode.value) {
for (const p of assignBatchProjects.value) {
const proj = rows.value.find(r => r.projectId === p.projectId)
const capS = proj ? Number(proj.totalSessions || 0) : 0
const capA = proj ? Number(proj.totalAmount || 0) : 0
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ${sessions} 场, 超过总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ¥${amount}, 超过总金额 ¥${capA}, 请调整`)
}
}
} else {
const capS = Number(assignForm.totalSessions || 0)
const capA = Number(assignForm.totalAmount || 0)
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`已分配 ${sessions} 场, 超过项目总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`已分配 ¥${amount}, 超过项目总金额 ¥${capA}, 请调整`)
}
}
const assignBody = valid.map(r => ({
execUserId: r.execUserId,
execUserName: r.execUserName,
@@ -718,10 +767,8 @@ async function submitAssign() {
try {
await bizUpdate('project', {
projectId: p.projectId,
orgId: assignForm.orgId,
orgName: assignForm.orgName,
totalAmount: amount,
totalSessions: sessions,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
@@ -740,10 +787,8 @@ async function submitAssign() {
// 单条模式
await bizUpdate('project', {
projectId: assignForm.projectId,
orgId: assignForm.orgId,
orgName: assignForm.orgName,
totalAmount: amount,
totalSessions: sessions,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
@@ -830,5 +875,18 @@ watch(() => route.path + (route.query._t || ''), () => load())
.score-item { display: flex; align-items: center; gap: 8px; }
.score-item span { width: 70px; color: #606266; font-size: 13px; }
/* 分配 dialog 总场次条 */
.assign-sessions-bar {
display: flex; align-items: center; gap: 18px;
margin: -4px 0 12px; padding: 10px 14px;
background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
font-size: 13px; color: #606266;
}
.assign-sessions-bar b { color: #303133; font-size: 14px; margin: 0 2px; }
.assign-sessions-bar .is-over b { color: #f56c6c; }
.assign-sessions-bar .over-warn { color: #f56c6c; font-weight: 600; }
.assign-sessions-bar .hint { color: #909399; font-size: 12px; }
.assign-sessions-bar .sep { color: #dcdfe6; margin: 0 4px; }
</style>
+45 -142
View File
@@ -69,7 +69,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="支持单位">
<el-select v-model="form.orgId" placeholder="请选择赞助公司" filterable clearable style="width:100%" @change="onSupporterPick">
<el-select v-model="form.sponsorAdminUserId" placeholder="请选择赞助方负责人" filterable clearable style="width:100%" @change="onSupporterPick">
<el-option v-for="u in supporterOptions" :key="u.userId"
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`"
:value="u.userId" />
@@ -104,37 +104,21 @@
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<p class="hint-text">*勾选公告类型后上传对应文件发布公告时可同时发布多个类型</p>
<p class="hint-text">*支持 PDF / 图片, 单文件 10MB; 名称规则: 公告文件类型 + 项目编号后两段 + 项目名称</p>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<el-checkbox v-model="n.enabled" :label="n.key" class="notice-check">
{{ n.label }}
</el-checkbox>
<el-upload
v-if="n.enabled"
<span class="notice-label">{{ n.label }}</span>
<oss-file-uploader
class="notice-uploader"
:show-file-list="true"
:file-list="getFileList(n)"
:before-upload="(file) => beforeUpload(file, n)"
:http-request="(opts) => httpUpload(opts, n, idx)"
:on-remove="(file) => onRemove(n, file)"
:on-preview="(file) => onPreview(file)"
accept=".pdf,.doc,.docx"
drag
action="#"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<div class="el-upload__tip" slot="tip">
支持 PDF / Word,单文件 ≤ 20MB
</div>
</el-upload>
<div v-else class="notice-disabled">勾选「{{ n.label }}」后可上传文件</div>
v-model="n.url"
:dir="`ry8080/project/${n.key}/`"
accept=".pdf,.png,.jpg,.jpeg,.gif,.webp"
:placeholder="`点击上传${n.label}`"
hint="支持 PDF / 图片"
block
/>
</div>
</div>
<p class="hint-text">*名称规则:公告文件类型+取项目编号的后两段+项目名称</p>
</el-form>
<!-- 底部按钮 -->
@@ -152,8 +136,8 @@ import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { listExecutor, listSupporters } from '@/api/system'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadToOss } from '@/utils/oss'
import request from '@/utils/request'
import OssFileUploader from '@/components/OssFileUploader.vue'
const router = useRouter()
const route = useRoute()
@@ -161,12 +145,10 @@ const isEdit = !!route.params.projectId
const projectId = route.params.projectId || null
const formRef = ref(null)
const submitting = ref(false)
const uploadingIdx = ref(-1)
const currentUploadN = ref(null)
const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', enabled: false, name: '' } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 支持方候选 (sponsor 角色)
const supporterOptions = ref([])
@@ -175,19 +157,24 @@ const form = reactive({
projectName: '', projectNo: '', projectForm: '',
totalSessions: 0, totalAmount: 0, manageFee: 0,
startTime: '', endTime: '',
// 赞助公司 (TODO 后续: 下拉源应改为 biz_org,orgType='sponsor' 列表, 当前是 sys_user)
orgId: null,
orgName: '',
// 赞助方负责人 (sys_user.role_type='sponsor', 原 org_id/org_name 字段已重命名)
sponsorAdminUserId: null,
sponsorAdminUserName: '',
// 提交截止(天)
submitDeadlineDays: 30,
roleRows: Array(6).fill(0).map(() => defaultRoleRow()),
// 公告附件(每个 notice 含 enabled/type/label/url/name)
// enabled: 是否启用该公告类型(对应原型 checkbox 邀请函/支持函/通知/日程)
roleRows: [
{ role: '主席', customName: '', amount: 0 },
{ role: '主持', customName: '', amount: 0 },
{ role: '讲者', customName: '', amount: 0 },
{ role: '点评/评审', customName: '', amount: 0 },
{ role: '讨论', customName: '', amount: 0 }
],
// 公告附件 (key/label/url/name, 没有 url 即为空, 不参与提交)
notices: [
{ key: 'invitation', label: '邀请函', enabled: false, url: '', name: '' },
{ key: 'supportLetter', label: '支持函', enabled: false, url: '', name: '' },
{ key: 'notice', label: '通知', enabled: false, url: '', name: '' },
{ key: 'schedule', label: '日程', enabled: false, url: '', name: '' }
{ key: 'invitation', label: '邀请函', url: '', name: '' },
{ key: 'supportLetter', label: '支持函', url: '', name: '' },
{ key: 'notice', label: '通知', url: '', name: '' },
{ key: 'schedule', label: '日程', url: '', name: '' }
]
})
@@ -235,8 +222,8 @@ async function loadProject() {
form.manageFee = p.manageFee || 0
form.startTime = p.startTime || ''
form.endTime = p.endTime || ''
form.orgId = p.orgId || null
form.orgName = p.orgName || ''
form.sponsorAdminUserId = p.sponsorAdminUserId || null
form.sponsorAdminUserName = p.sponsorAdminUserName || ''
form.submitDeadlineDays = p.submitDeadlineDays || 30
// 公告文件反显: 优先 publicityFiles JSON 数组, 否则兼容旧字段
const files = []
@@ -248,7 +235,7 @@ async function loadProject() {
// 按 type 反显到对应 notice 行
for (const n of form.notices) {
const m = files.find(f => f.type === n.key)
if (m) { n.url = m.url || ''; n.name = m.name || ''; n.enabled = !!m.url }
if (m) { n.url = m.url || ''; n.name = m.name || '' }
}
} else {
// 兼容老数据
@@ -262,7 +249,6 @@ async function loadProject() {
for (const n of form.notices) {
n.url = noticeMap[n.key] || ''
n.name = n.url ? (n.url.split('/').pop() || '') : ''
n.enabled = !!n.url
}
}
// 加载已有执行方分配 (暂不实现, 留 hook)
@@ -284,7 +270,7 @@ async function loadSupporters(query = '') {
function onSupporterPick(userId) {
const u = supporterOptions.value.find(x => x.userId === userId)
if (u) form.orgName = u.userName || ''
if (u) form.sponsorAdminUserName = u.userName || ''
}
@@ -294,81 +280,6 @@ onMounted(() => {
loadProject()
loadSupporters('')
})
function beforeUpload(file, n) {
// 文件大小校验: 限制 20MB
const maxSize = 20 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 20MB')
return false
}
// 文件类型校验
const accept = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg']
const ext = '.' + file.name.split('.').pop().toLowerCase()
if (!accept.includes(ext)) {
ElMessage.error('仅支持 PDF/Word/图片格式')
return false
}
return true
}
async function httpUpload(opts, n, idx) {
const file = opts.file
uploadingIdx.value = idx
currentUploadN.value = n
const dir = 'ry8080/project/' + n.key + '/'
try {
const url = await uploadToOss(file, dir)
n.url = url
n.name = file.name
n.enabled = true
ElMessage.success(`${n.label} 上传成功`)
} catch (err) {
ElMessage.error(`上传失败: ${err.message || err}`)
n.url = ''
n.name = ''
} finally {
uploadingIdx.value = -1
currentUploadN.value = null
}
}
// el-upload 内置 file-list 需要从 n.url 转换成 {name, url} 对象
function getFileList(n) {
if (!n.url) return []
// 从 url 截取文件名
const url = n.url
const name = url.split('/').pop() || `${n.label}.pdf`
return [{ name, url }]
}
function onPreview(file) {
if (file.url) window.open(file.url, '_blank')
}
function onRemove(n, file) {
n.url = ''
}
function previewFile(n) {
if (n.url) window.open(n.url, '_blank')
}
function downloadFile(n) {
if (!n.url) return
// 触发浏览器下载
const a = document.createElement('a')
a.href = n.url
a.download = n.name || (n.label + '.pdf')
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function removeNotice(idx) {
const n = form.notices[idx]
if (!n) return
ElMessageBox.confirm(`确定删除"${n.label}"附件?`, '提示', { type: 'warning' })
.then(() => { n.url = ''; ElMessage.success('已删除') })
.catch(() => {})
}
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
@@ -390,8 +301,8 @@ async function submit(mode = 'save') {
manageFee: form.manageFee,
startTime: form.startTime,
endTime: form.endTime,
orgId: form.orgId,
orgName: form.orgName,
sponsorAdminUserId: form.sponsorAdminUserId,
sponsorAdminUserName: form.sponsorAdminUserName,
submitDeadlineDays: form.submitDeadlineDays
}
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl)
@@ -473,30 +384,22 @@ async function submit(mode = 'save') {
.role-custom { flex: 1; max-width: 180px; }
.role-amount { width: 180px; }
.row-actions { display: flex; gap: 8px; }
.notice-list { display: flex; flex-direction: column; gap: 12px; }
.notice-row { display: flex; align-items: stretch; gap: 16px; margin-bottom: 16px; }
.notice-list { display: flex; flex-direction: column; gap: 10px; }
.notice-row { display: flex; align-items: stretch; gap: 12px; margin-bottom: 12px; }
.notice-label {
width: 100px;
width: 90px;
display: flex; align-items: center; justify-content: flex-end;
color: #606266; font-size: 14px;
}
.notice-uploader { flex: 1; }
.notice-uploader :deep(.el-upload) { width: 100%; }
.notice-uploader :deep(.el-upload-dragger) {
padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
}
.notice-uploader :deep(.el-upload-dragger .el-upload__text) {
font-size: 13px; color: #606266;
}
.notice-uploader :deep(.el-upload-dragger .el-upload__tip) {
font-size: 12px; color: #909399; margin-top: 2px;
}
.notice-uploader :deep(.el-upload__tip) { margin-top: 0; }
.notice-uploader :deep(.el-upload-list) { margin: 0; }
.notice-actions { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-width: 70px; }
.notice-actions .el-button { padding: 2px 6px; font-size: 12px; }
.notice-disabled { flex: 1; color: #c0c4cc; font-size: 13px; padding: 8px 0; }
/* 紧凑版 (覆盖 OssFileUploader 默认尺寸) */
.notice-uploader :deep(.ht-file-upload) { padding: 6px 12px; min-height: 40px; gap: 8px; }
.notice-uploader :deep(.placeholder-text) { font-size: 13px; }
.notice-uploader :deep(.placeholder-hint) { font-size: 11px; }
.notice-uploader :deep(.file-name) { font-size: 13px; }
.notice-uploader :deep(.file-type) { font-size: 11px; }
.notice-uploader :deep(.file-icon) { font-size: 18px; }
.notice-uploader :deep(.file-icon svg) { width: 18px; height: 18px; }
/* 底部按钮 */
.form-actions { display: flex; justify-content: center; gap: 16px; padding: 20px 0 4px; border-top: 1px solid #f0f0f0; margin-top: 8px; }
+374
View File
@@ -0,0 +1,374 @@
<template>
<div class="detail-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<main class="container">
<article class="article-card" v-loading="loading">
<header class="article-head">
<h1 class="article-title">{{ article.title || (type === 'agreement' ? '用户服务协议' : '隐私政策') }}</h1>
<div class="article-meta" v-if="article.updateTime">最后更新: {{ fmt(article.updateTime) }}</div>
</header>
<div class="article-body" v-html="article.content || '<p class=\'empty\'>暂无内容</p>'" />
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
</svg>
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
const article = ref({})
const loading = ref(false)
const type = ref('')
async function load() {
const t = route.params.type
type.value = t
if (!t) return
loading.value = true
try {
const r = await request({ url: `/business/public/article/${t}`, method: 'get' })
article.value = (r.data && r.data.data) || r.data || {}
} catch (e) {
article.value = {}
} finally { loading.value = false }
}
function fmt(d) {
if (!d) return ''
const date = new Date(d)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/leader/home')
}
}
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
load()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
watch(() => route.params.type, load)
</script>
<style scoped>
/* ========== 基础 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #1f2937;
background: #f5f6f8;
min-width: 1354px;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
/* ========== 顶部导航 ========== */
.top-nav {
position: sticky;
top: 0;
z-index: 1000;
height: 72px;
padding: 0 60px;
background: var(--brand-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.3s;
}
.top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); }
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 36px; height: 36px;
background: #fff;
color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600;
}
.logo-text { display: flex; flex-direction: column; line-height: 1.2; }
.logo-title { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 0.5px; }
.logo-subtitle { font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 2px; letter-spacing: 0.3px; }
.nav-list {
flex: 1; display: flex; align-items: center; justify-content: center;
gap: 36px; list-style: none;
}
.nav-item { position: relative; height: 72px; display: flex; align-items: center; }
.nav-link {
font-size: 15px; font-weight: 500; color: rgba(255, 255, 255, 0.85);
cursor: pointer; transition: color 0.25s; position: relative;
height: 72px; display: flex; align-items: center;
}
.nav-link::after {
content: ''; position: absolute; left: 50%; bottom: 0;
width: 0; height: 2px; background: #fff;
transform: translateX(-50%); transition: width 0.3s ease;
}
.nav-item:hover .nav-link, .nav-link.active { color: #fff; }
.nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; }
.top-tools { display: flex; align-items: center; gap: 16px; }
.login-btn {
padding: 7px 20px;
background: #fff;
color: var(--brand-primary);
font-size: 13px; font-weight: 500;
cursor: pointer; transition: background 0.2s; letter-spacing: 1px;
}
.login-btn:hover { background: #f3f4f6; }
.user-link {
display: inline-flex; align-items: center; gap: 6px;
color: #fff; font-size: 14px; cursor: pointer;
}
/* ========== 主体 ========== */
.container {
max-width: 1354px;
margin: 0 auto;
padding: 40px 60px 60px; /* 顶部加 40px 留白, 拉开与 sticky navcat 的距离 */
}
.back-link {
display: inline-flex; align-items: center; gap: 6px;
margin: 28px 0 20px;
font-size: 13px; color: #6b7280;
cursor: pointer; transition: color 0.2s;
}
.back-link:hover { color: var(--brand-primary); }
/* ========== 文章卡片 ========== */
.article-card {
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
padding: 56px 80px 64px;
min-height: 480px;
}
.article-head {
text-align: center;
border-bottom: 1px solid #eef0f3;
padding-bottom: 28px;
margin-bottom: 32px;
}
.article-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
letter-spacing: 1px;
line-height: 1.4;
}
.article-meta {
margin-top: 12px;
font-size: 13px;
color: #9ca3af;
}
.article-body {
font-size: 15px;
line-height: 2;
color: #374151;
}
.article-body :deep(h1),
.article-body :deep(h2),
.article-body :deep(h3) {
font-weight: 700;
color: #1f2937;
margin: 24px 0 14px;
line-height: 1.5;
}
.article-body :deep(h1) { font-size: 22px; text-align: center; }
.article-body :deep(h2) { font-size: 18px; }
.article-body :deep(h3) { font-size: 16px; }
.article-body :deep(p) {
margin: 10px 0;
text-indent: 2em;
text-align: justify;
}
.article-body :deep(ul),
.article-body :deep(ol) {
margin: 12px 0 12px 2em;
}
.article-body :deep(li) { margin: 6px 0; }
.article-body :deep(.empty) {
text-align: center;
color: #c0c4cc;
text-indent: 0;
padding: 60px 0;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
background: #fff;
color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600; flex-shrink: 0;
}
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.article-card { padding: 32px 24px; }
}
</style>
+31 -78
View File
@@ -77,83 +77,16 @@
<div class="specialty-inner">
<div class="section-title">七大专项计划</div>
<div class="specialty-list">
<div class="specialty-card">
<div class="specialty-no">01</div>
<div v-for="(p, i) in specialPlans" :key="p.id" class="specialty-card" @click="openPlan(p.id)">
<div class="specialty-no">{{ String(i + 1).padStart(2, '0') }}</div>
<div class="specialty-body">
<h3 class="specialty-title">规范化诊疗及医疗质量提升专项计划(2026)</h3>
<p class="specialty-desc">聚焦临床诊疗规范制定与质量提升,推动各级医疗机构诊疗标准化同质化</p>
<h3 class="specialty-title">{{ p.title }}</h3>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">02</div>
<div class="specialty-body">
<h3 class="specialty-title">科研创新专项行动计划(2026-2030)</h3>
<p class="specialty-desc">支持医学科研创新,推动整合医学研究成果转化与产业化应用,构建协同创新生态</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">03</div>
<div class="specialty-body">
<h3 class="specialty-title">医疗卫生人才培育专项行动计划(2026-2030)</h3>
<p class="specialty-desc">建立多层次医学人才培养体系,重点加强基层医疗人才与跨学科复合型人才建设</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">04</div>
<div class="specialty-body">
<h3 class="specialty-title">医院管理及高质量发展促进专项计划(2026-2030)</h3>
<p class="specialty-desc">推动医院管理现代化,提升运营效率与服务水平,助力公立医院高质量发展</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">05</div>
<div class="specialty-body">
<h3 class="specialty-title">社会公益与可及性提升专项计划(2026-2030)</h3>
<p class="specialty-desc">聚焦医疗服务的可及性与公平性,开展公益救助基层帮扶与健康科普活动</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">06</div>
<div class="specialty-body">
<h3 class="specialty-title">政学协作综合项目专项计划(2026-2030)</h3>
<p class="specialty-desc">搭建政府与学术机构协同平台,推动政策研究与决策支持,促进政产学研用深度融合</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">07</div>
<div class="specialty-body">
<h3 class="specialty-title">组织建设与内部治理专项计划(2026-2030)</h3>
<p class="specialty-desc">完善学会组织架构,健全内部治理机制,提升规范化专业化信息化管理水平</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div v-if="!specialPlans.length" class="specialty-empty">暂无专项计划</div>
</div>
</div>
</section>
@@ -215,11 +148,26 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const specialPlans = ref([])
async function loadSpecialPlans() {
try {
const r = await request({ url: '/business/public/specialPlan/list', method: 'get' })
specialPlans.value = (r.data && r.data.data) || r.data || []
} catch (e) {
specialPlans.value = []
}
}
function openPlan(id) {
window.open(`/#/special-plan/${id}`, '_blank')
}
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
@@ -266,12 +214,6 @@ const planSvg = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg
<g transform="translate(904 362)"><polygon points="-30,0 -15,-26 15,-26 30,0 15,26 -15,26" fill="url(#hexGrad)"/><text y="6" font-size="19">07</text></g>
<g transform="translate(986 468)"><polygon points="-34,0 -17,-29 17,-29 34,0 17,29 -17,29" fill="url(#hexGrad)"/><text y="7" font-size="22">08</text></g>
</g>
<g transform="translate(600 360)">
<circle cx="0" cy="0" r="40" fill="#fff" stroke="#e0e7ff" stroke-width="2"/>
<circle cx="0" cy="0" r="30" fill="url(#centerGrad)"/>
<rect x="-16" y="-5" width="32" height="10" fill="#fff"/>
<rect x="-5" y="-16" width="10" height="32" fill="#fff"/>
</g>
<text x="600" y="430" fill="var(--brand-primary)" text-anchor="middle" font-family="Microsoft YaHei, sans-serif" font-size="32" font-weight="bold" letter-spacing="6">2025-2030年</text>
<line x1="540" y1="447" x2="600" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
<line x1="600" y1="447" x2="660" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
@@ -291,7 +233,10 @@ function handleScroll() {
isScrolled.value = window.scrollY > 10
}
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
loadSpecialPlans()
})
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
function onHome() { isScrolled.value = false }
@@ -632,8 +577,16 @@ a { color: inherit; text-decoration: none; }
background: #fff;
border: 1px solid #e5e7eb;
display: flex;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
}
.specialty-empty {
grid-column: 1 / -1;
text-align: center;
color: #9ca3af;
padding: 60px 0;
font-size: 14px;
}
.specialty-card:hover {
border-color: var(--brand-primary);
+3 -2
View File
@@ -56,7 +56,7 @@
<span class="notice-item-no">{{ String((currentPage - 1) * PAGE_SIZE + i + 1).padStart(3, '0') }}</span>
<span class="notice-date">{{ fmtDate(n.date) }}</span>
<span class="notice-title">
<span v-if="n.projectName" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectName }}</span>
<span v-if="n.projectNo" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectNo }}</span>
{{ n.title }}
</span>
</div>
@@ -172,8 +172,9 @@ async function load() {
annId: r.projectId,
date: r.publishTime,
type,
title: r.projectName,
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
fileUrl: urlMap[type] || r.invitationUrl || r.supportLetterUrl || r.publishUrl || ''
}
})
+34 -64
View File
@@ -156,28 +156,27 @@
</div>
</footer>
<!-- 分享二维码弹窗 -->
<div class="modal-overlay" :class="showQr ? 'active' : ''" @click.self="showQr = false">
<div class="modal" @click.stop>
<h3 class="modal-title">分享本页</h3>
<div class="modal-qr-wrap">
<span class="qr-corner qr-corner-tl"></span>
<span class="qr-corner qr-corner-tr"></span>
<span class="qr-corner qr-corner-bl"></span>
<span class="qr-corner qr-corner-br"></span>
<div class="modal-qr">
<img v-if="qrUrl" :src="qrUrl" alt="分享二维码" class="qr-img" />
<span v-else class="qr-loading">
<span class="qr-dot"></span><span class="qr-dot"></span><span class="qr-dot"></span>
生成中...
</span>
</div>
<div class="qr-brand">北京整合医学学会</div>
<!-- 分享二维码弹窗 (el-dialog 自带右上角 X 关闭按钮 + Esc + 遮罩点击) -->
<el-dialog v-model="showQr" title="分享本页" width="420px" align-center destroy-on-close>
<div class="modal-qr-wrap">
<span class="qr-corner qr-corner-tl"></span>
<span class="qr-corner qr-corner-tr"></span>
<span class="qr-corner qr-corner-bl"></span>
<span class="qr-corner qr-corner-br"></span>
<div class="modal-qr">
<img v-if="qrUrl" :src="qrUrl" alt="分享二维码" class="qr-img" />
<span v-else class="qr-loading">
<span class="qr-dot"></span><span class="qr-dot"></span><span class="qr-dot"></span>
生成中...
</span>
</div>
<p class="modal-tip">扫码查看{{ ann?.projectName || '项目公示' }}公示详情</p>
<button class="modal-close" @click="showQr = false"> </button>
<div class="qr-brand">北京整合医学学会</div>
</div>
</div>
<p class="modal-tip">扫码查看{{ ann?.projectName || '项目公示' }}公示详情</p>
<template #footer>
<el-button type="primary" :disabled="!qrUrl" @click="onDownloadQr">下载二维码</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -388,6 +387,17 @@ async function onSignup() {
function onKeydown(e) { if (e.key === 'Escape') showQr.value = false }
function handleScroll() { isScrolled.value = window.scrollY > 10 }
// 下载二维码: qrUrl 是 QRCode.toDataURL 生成的 data:image/png;base64,... 直接 <a download> 即可
function onDownloadQr() {
if (!qrUrl.value) return
const a = document.createElement('a')
a.href = qrUrl.value
a.download = `${(ann.value?.projectName || '公示')}_分享二维码.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('keydown', onKeydown)
@@ -981,39 +991,11 @@ a { color: inherit; text-decoration: none; }
}
}
/* ========== 二维码弹窗 ========== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-overlay.active { display: flex; }
.modal {
background: #fff;
padding: 32px 36px;
width: 320px;
text-align: center;
border: 1px solid #e5e7eb;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 20px;
letter-spacing: 1px;
}
/* ========== 二维码弹窗 (el-dialog 自带标题/X/footer, 这里只写内部 QR 样式) ========== */
.modal-qr-wrap {
position: relative;
width: 220px;
margin: 0 auto 20px;
margin: 0 auto 16px;
padding: 12px 12px 8px;
background: linear-gradient(135deg, #ffffff 0%, #f7faff 100%);
border-radius: 12px;
@@ -1076,22 +1058,10 @@ a { color: inherit; text-decoration: none; }
.modal-tip {
font-size: 12px;
color: #9ca3af;
margin-bottom: 20px;
margin: 0;
text-align: center;
}
.modal-close {
width: 100%;
height: 38px;
background: var(--brand-primary);
color: #fff;
border: none;
font-size: 14px;
cursor: pointer;
letter-spacing: 2px;
}
.modal-close:hover { background: var(--brand-primary-deep); }
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
@@ -0,0 +1,341 @@
<template>
<div class="detail-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<main class="container">
<article class="plan-card" v-loading="loading">
<header class="plan-head">
<h1 class="plan-title">{{ plan.title || '专项计划' }}</h1>
<div class="plan-meta" v-if="plan.updateTime">最后更新: {{ fmt(plan.updateTime) }}</div>
</header>
<!-- 富文本内容 -->
<div v-if="!plan.contentType || plan.contentType === 'rich'"
class="plan-body plan-body-rich"
v-html="plan.content || '<p class=\'empty\'>暂无内容</p>'" />
<!-- 上传文件: PDF / PNG -->
<div v-else class="plan-body plan-body-file">
<iframe v-if="isPdf(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-frame"
frameborder="0"></iframe>
<img v-else-if="isImg(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-img"
alt="专项计划文件" />
<div v-else class="content-empty">
<el-icon size="64"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg></el-icon>
<p>暂无可查看的文件</p>
<p class="hint">请上传 PDF PNG 格式</p>
</div>
</div>
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
</svg>
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
const plan = ref({})
const loading = ref(false)
async function load() {
const id = route.params.id
if (!id) return
loading.value = true
try {
const r = await request({ url: `/business/public/specialPlan/${id}`, method: 'get' })
plan.value = (r.data && r.data.data) || r.data || {}
} catch (e) {
plan.value = {}
} finally { loading.value = false }
}
function fmt(d) {
if (!d) return ''
const date = new Date(d)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function isPdf(url) {
if (!url) return false
return /\.pdf(\?|$)/i.test(url.split('?')[0])
}
function isImg(url) {
if (!url) return false
return /\.(png|jpg|jpeg|gif|webp)(\?|$)/i.test(url.split('?')[0])
}
// OSS bucket 默认 Content-Disposition: attachment, PDF 会被强制下载.
// 走 /common/oss/proxy 后端代理改写为 inline.
// 参考 PublicityDetail.vue 的 proxyUrl 逻辑
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return '/dev-api/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/leader/home')
}
}
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
load()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
watch(() => route.params.id, load)
</script>
<style scoped>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #1f2937;
background: #f5f6f8;
min-width: 1354px;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
/* ===== 顶部导航 (与 PublicityDetail 一致) ===== */
.top-nav {
position: sticky; top: 0; z-index: 1000;
height: 72px; padding: 0 60px;
background: var(--brand-primary);
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex; align-items: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
transition: box-shadow 0.3s;
}
.top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0,0,0,0.25); }
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 36px; height: 36px;
background: #fff; color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600;
}
.logo-text { display: flex; flex-direction: column; line-height: 1.2; }
.logo-title { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 0.5px; }
.logo-subtitle { font-size: 11px; color: rgba(255,255,255,0.6); margin-top: 2px; letter-spacing: 0.3px; }
.nav-list { flex: 1; display: flex; align-items: center; justify-content: center; gap: 36px; list-style: none; }
.nav-item { position: relative; height: 72px; display: flex; align-items: center; }
.nav-link {
font-size: 15px; font-weight: 500; color: rgba(255,255,255,0.85);
cursor: pointer; transition: color 0.25s; position: relative;
height: 72px; display: flex; align-items: center;
}
.nav-link::after {
content: ''; position: absolute; left: 50%; bottom: 0;
width: 0; height: 2px; background: #fff;
transform: translateX(-50%); transition: width 0.3s ease;
}
.nav-item:hover .nav-link, .nav-link.active { color: #fff; }
.nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; }
.top-tools { display: flex; align-items: center; gap: 16px; }
.login-btn {
padding: 7px 20px; background: #fff; color: var(--brand-primary);
font-size: 13px; font-weight: 500;
cursor: pointer; transition: background 0.2s; letter-spacing: 1px;
}
.login-btn:hover { background: #f3f4f6; }
.user-link { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; cursor: pointer; }
/* ===== 主体 ===== */
.container { max-width: 1354px; margin: 0 auto; padding: 28px 60px 60px; }
.plan-card {
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
padding: 56px 80px 64px;
min-height: 480px;
}
.plan-head { text-align: center; border-bottom: 1px solid #eef0f3; padding-bottom: 28px; margin-bottom: 32px; }
.plan-title { font-size: 26px; font-weight: 700; color: #1f2937; letter-spacing: 1px; line-height: 1.4; }
.plan-meta { margin-top: 12px; font-size: 13px; color: #9ca3af; }
/* 富文本 (与 ArticleView 一致) */
.plan-body-rich {
font-size: 15px; line-height: 2; color: #374151;
}
.plan-body-rich :deep(h1),
.plan-body-rich :deep(h2),
.plan-body-rich :deep(h3) { font-weight: 700; color: #1f2937; margin: 24px 0 14px; line-height: 1.5; }
.plan-body-rich :deep(h1) { font-size: 22px; text-align: center; }
.plan-body-rich :deep(h2) { font-size: 18px; }
.plan-body-rich :deep(h3) { font-size: 16px; }
.plan-body-rich :deep(p) { margin: 10px 0; text-indent: 2em; text-align: justify; }
.plan-body-rich :deep(ul),
.plan-body-rich :deep(ol) { margin: 12px 0 12px 2em; }
.plan-body-rich :deep(li) { margin: 6px 0; }
.plan-body-rich :deep(.empty) { text-align: center; color: #c0c4cc; text-indent: 0; padding: 60px 0; }
/* 文件预览 */
.plan-body-file { padding: 0; min-height: 600px; }
.content-frame { width: 100%; height: 700px; display: block; border: 0; }
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
.content-empty { padding: 80px 20px; text-align: center; color: #c0c4cc; }
.content-empty p { margin: 8px 0 0; }
.content-empty .hint { font-size: 12px; }
/* ===== 底部 ===== */
.footer { background: var(--brand-primary-darker); color: rgba(255,255,255,0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
background: #fff; color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600; flex-shrink: 0;
}
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255,255,255,0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255,255,255,0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px; position: relative;
}
.footer-col h4::after { content: ''; position: absolute; left: 0; bottom: 0; width: 24px; height: 2px; background: #93c5fd; }
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255,255,255,0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.plan-card { padding: 32px 24px; }
}
</style>
+26 -21
View File
@@ -112,8 +112,8 @@
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
<el-button class="btn-sms" :disabled="phoneSmsLocked || phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsLocked ? '发送中...' : phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
@@ -134,6 +134,9 @@ import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: phoneSmsLocked, run: sendPhoneSmsOnce } = useAsyncLock()
const store = useUserStore()
const tab = ref('profile')
@@ -350,25 +353,27 @@ async function sendSms() {
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
await sendPhoneSmsOnce(async () => {
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
})
}
async function onChangePhone() {
+4 -8
View File
@@ -172,12 +172,8 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司类型" :span="2">
<el-tag :type="detail.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
{{ detail.orgType === 'sponsor' ? '赞助方' : '执行方' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司类型" :span="2">赞助方</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行/未执行">{{ detail.doneSessions || 0 }} / {{ detail.todoSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ formatMoney(detail.totalAmount) }}</el-descriptions-item>
@@ -280,8 +276,8 @@ function onCreateMeeting(row) {
}
function onSupportUnit(row) {
if (!row.orgId) { ElMessage.warning('该项目未关联赞助公司'); return }
ElMessage.info(`查看赞助公司: ${row.orgName || row.orgId}`)
if (!row.sponsorAdminUserId) { ElMessage.warning('该项目未关联赞助方负责人'); return }
ElMessage.info(`查看赞助方负责人: ${row.sponsorAdminUserName || row.sponsorAdminUserId}`)
}
async function onDeleteNotice(row) {
try { await ElMessageBox.confirm(`确定删除「${row.projectName}」的所有公告吗?删除后无法恢复`, '删除公告', { type: 'warning' }) } catch { return }
+36 -17
View File
@@ -3,20 +3,22 @@
-- ============================================================================
-- 项目
-- 2026-08-16: org_id/org_name/org_type 重命名为 sponsor_admin_user_id/sponsor_admin_user_name (存 sys_user.user_id, 不是公司)
-- sponsor01=钱七, user_id=104
INSERT INTO biz_project(project_no, project_name, project_form, total_sessions, done_sessions, todo_sessions,
total_amount, available_amount, paid_labor_amount, paid_meeting_amount, manage_fee,
is_finished, is_settled, rating_score, org_name, org_type,
is_finished, is_settled, rating_score, sponsor_admin_user_id, sponsor_admin_user_name,
start_time, end_time, create_by, create_time)
VALUES
('ZH-2026-658', '小牛血清创新应用研讨会', '线上', 26, 16, 10, 3112000.00, 1112000.00, 1000000.00, 1000000.00, 1112000.00, '0', '0', 5.0, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-659', '整合医学学会项目评审会', '线下', 18, 12, 6, 2500000.00, 980000.00, 900000.00, 620000.00, 980000.00, '0', '0', 4.5, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-650', '智慧医院建设项目方案论证会', '线上+线下', 12, 7, 5, 1800000.00, 760000.00, 700000.00, 340000.00, 760000.00, '0', '0', 4.0, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-645', '基层医疗改革试点方案中期评估会', '线下', 20, 14, 6, 2800000.00, 1050000.00, 950000.00, 800000.00, 1050000.00, '0', '0', 4.8, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-640', '数字化医疗转型方案评审会', '线上', 22, 15, 7, 3000000.00, 1200000.00, 1100000.00, 700000.00, 1200000.00, '0', '0', 4.2, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-635', '临床医学研究方案评议会', '线下', 14, 9, 5, 2200000.00, 820000.00, 740000.00, 640000.00, 820000.00, '1', '1', 4.6, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-628', '公共卫生应急体系建设研讨会', '线下', 16, 11, 5, 2400000.00, 900000.00, 820000.00, 680000.00, 900000.00, '0', '0', 4.4, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-622', '中医诊疗标准化研究论坛', '线下', 10, 6, 4, 1500000.00, 560000.00, 500000.00, 440000.00, 560000.00, '0', '0', 4.7, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-618', '医疗 AI 辅助诊断应用论坛', '线上+线下', 8, 4, 4, 1200000.00, 480000.00, 420000.00, 300000.00, 480000.00, '0', '0', 4.9, '北京XXXX有限公司', 'sponsor', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW());
('ZH-2026-658', '小牛血清创新应用研讨会', '线上', 26, 16, 10, 3112000.00, 1112000.00, 1000000.00, 1000000.00, 1112000.00, '0', '0', 5.0, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-659', '整合医学学会项目评审会', '线下', 18, 12, 6, 2500000.00, 980000.00, 900000.00, 620000.00, 980000.00, '0', '0', 4.5, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-650', '智慧医院建设项目方案论证会', '线上+线下', 12, 7, 5, 1800000.00, 760000.00, 700000.00, 340000.00, 760000.00, '0', '0', 4.0, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-645', '基层医疗改革试点方案中期评估会', '线下', 20, 14, 6, 2800000.00, 1050000.00, 950000.00, 800000.00, 1050000.00, '0', '0', 4.8, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-640', '数字化医疗转型方案评审会', '线上', 22, 15, 7, 3000000.00, 1200000.00, 1100000.00, 700000.00, 1200000.00, '0', '0', 4.2, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-635', '临床医学研究方案评议会', '线下', 14, 9, 5, 2200000.00, 820000.00, 740000.00, 640000.00, 820000.00, '1', '1', 4.6, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-628', '公共卫生应急体系建设研讨会', '线下', 16, 11, 5, 2400000.00, 900000.00, 820000.00, 680000.00, 900000.00, '0', '0', 4.4, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-622', '中医诊疗标准化研究论坛', '线下', 10, 6, 4, 1500000.00, 560000.00, 500000.00, 440000.00, 560000.00, '0', '0', 4.7, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-618', '医疗 AI 辅助诊断应用论坛', '线上+线下', 8, 4, 4, 1200000.00, 480000.00, 420000.00, 300000.00, 480000.00, '0', '0', 4.9, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW());
-- 项目角色劳务设置
INSERT INTO biz_project_labor_role(project_id, role_name, labor_amount)
@@ -140,13 +142,19 @@ SELECT 'ZH-2026-658', '小牛血清创新应用研讨会', '孙八', '北京XXXX
SELECT 'ZH-2026-658', '小牛血清创新应用研讨会', '周九', '北京XXXXXX公司', '华北大区', '项目负责人', '13512321603', '已入库', NOW() UNION ALL
SELECT 'ZH-2026-658', '小牛血清创新应用研讨会', '吴十', '北京XXXXXX公司', '华北大区', '项目负责人', '13512321604', '已入库', NOW();
-- 公示公告
INSERT INTO biz_announcement(title, ann_type, publish_time, project_no, create_by, create_time)
VALUES
('邀请函 2026第2323号 "恺启新生—胃癌 CAR-T 细胞治疗系列会"', '邀请函', '2026-05-06 10:00:00', 'ZH-2026-658', 'admin', NOW()),
('会议通知 2026第2277号 北京同仁医院建院140周年学术会议', '通知', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW()),
('支持函 2026第2278号 项目启动支持', '支持函', '2026-05-06 10:00:00', 'ZH-2026-650', 'admin', NOW()),
('会议日程 2026第2279号 整合医学学会项目评审会', '日程', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW());
-- 公示公告 (2026-08-16: biz_announcement → biz_publicity, ann_type → announce_type)
INSERT INTO biz_publicity(title, announce_type, file_url, publish_time, project_id, project_no, project_name, status, create_by, create_time)
SELECT a.title, a.announce_type, NULL, a.publish_time,
(SELECT project_id FROM biz_project WHERE project_no=a.project_no LIMIT 1),
a.project_no,
(SELECT project_name FROM biz_project WHERE project_no=a.project_no LIMIT 1),
'1', a.create_by, a.create_time
FROM (
SELECT '邀请函 2026第2323号 "恺启新生—胃癌 CAR-T 细胞治疗系列会"' AS title, 'invitation' AS announce_type, '2026-05-06 10:00:00' AS publish_time, 'ZH-2026-658' AS project_no, 'admin' AS create_by, NOW() AS create_time UNION ALL
SELECT '会议通知 2026第2277号 北京同仁医院建院140周年学术会议', 'notice', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW() UNION ALL
SELECT '支持函 2026第2278号 项目启动支持', 'support', '2026-05-06 10:00:00', 'ZH-2026-650', 'admin', NOW() UNION ALL
SELECT '会议日程 2026第2279号 整合医学学会项目评审会', 'agenda', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW()
) a;
-- 邀请函
INSERT INTO biz_invitation(project_id, project_no, title, publish_time)
@@ -211,3 +219,14 @@ VALUES
INSERT INTO sys_user_role(user_id, role_id)
SELECT user_id, 2 FROM sys_user WHERE user_name IN ('leader01','manager01','doctor01','executor01','sponsor01');
-- admin 已在原 RuoYi 脚本中,保留
-- ============================================================================
-- 平台协议/隐私政策 初始数据 (2026-08-16)
-- ============================================================================
INSERT INTO biz_article(title, type, content, status, create_by, create_time, remark) VALUES
('用户服务协议', 'agreement',
'<h2>用户服务协议</h2><p>欢迎使用 BAHIM 项目管理平台。请仔细阅读本协议, 注册即视为同意全部条款。</p><p>1. 用户应保证所提供资料真实有效。</p><p>2. 用户应妥善保管账号密码。</p><p>3. 平台保留最终解释权。</p>',
'0', 'admin', NOW(), '注册页底部 [我已阅读并同意《用户协议》] 链接指向此处'),
('隐私政策', 'privacy',
'<h2>隐私政策</h2><p>BAHIM 平台高度重视用户隐私, 严格按照法律法规要求保护您的个人信息。</p><p>1. 收集信息范围: 注册手机号、姓名、单位名称等业务必要字段。</p><p>2. 信息用途: 仅用于项目协作, 不会用于商业推广或对外披露。</p><p>3. 您的权利: 可随时在账号信息页查看和更正个人信息。</p>',
'0', 'admin', NOW(), '注册页底部 [《隐私政策》] 链接指向此处');
+73
View File
@@ -0,0 +1,73 @@
-- ============================================================================
-- 重新灌 5 张被清空的表 (2026-08-16, biz_schema.sql 误执行后)
-- 仅插入被清空的 5 张表: biz_project / biz_meeting / biz_expert / biz_project_assign / biz_publicity
-- 其他表已有数据, 不动
-- ============================================================================
-- 1. biz_project (9 条, sponsor_admin_user_id=104=sponsor01, sponsor_admin_user_name='赞助方钱七')
INSERT INTO biz_project(project_no, project_name, project_form, total_sessions, done_sessions, todo_sessions,
total_amount, available_amount, paid_labor_amount, paid_meeting_amount, manage_fee,
is_finished, is_settled, rating_score, sponsor_admin_user_id, sponsor_admin_user_name,
start_time, end_time, create_by, create_time)
VALUES
('ZH-2026-658', '小牛血清创新应用研讨会', '线上', 26, 16, 10, 3112000.00, 1112000.00, 1000000.00, 1000000.00, 1112000.00, '0', '0', 5.0, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-659', '整合医学学会项目评审会', '线下', 18, 12, 6, 2500000.00, 980000.00, 900000.00, 620000.00, 980000.00, '0', '0', 4.5, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-650', '智慧医院建设项目方案论证会', '线上+线下', 12, 7, 5, 1800000.00, 760000.00, 700000.00, 340000.00, 760000.00, '0', '0', 4.0, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-645', '基层医疗改革试点方案中期评估会', '线下', 20, 14, 6, 2800000.00, 1050000.00, 950000.00, 800000.00, 1050000.00, '0', '0', 4.8, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-640', '数字化医疗转型方案评审会', '线上', 22, 15, 7, 3000000.00, 1200000.00, 1100000.00, 700000.00, 1200000.00, '0', '0', 4.2, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-635', '临床医学研究方案评议会', '线下', 14, 9, 5, 2200000.00, 820000.00, 740000.00, 640000.00, 820000.00, '1', '1', 4.6, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-628', '公共卫生应急体系建设研讨会', '线下', 16, 11, 5, 2400000.00, 900000.00, 820000.00, 680000.00, 900000.00, '0', '0', 4.4, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-622', '中医诊疗标准化研究论坛', '线下', 10, 6, 4, 1500000.00, 560000.00, 500000.00, 440000.00, 560000.00, '0', '0', 4.7, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW()),
('ZH-2026-618', '医疗 AI 辅助诊断应用论坛', '线上+线下', 8, 4, 4, 1200000.00, 480000.00, 420000.00, 300000.00, 480000.00, '0', '0', 4.9, 104, '赞助方钱七', '2026-05-11 00:00:00', '2026-06-30 00:00:00', 'admin', NOW());
-- 2. biz_meeting (5 条, 关联 biz_project)
INSERT INTO biz_meeting(business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, current_stage, create_by, create_time)
SELECT '5643145673', project_id, 'ZH-2026-658', '小牛血清创新应用研讨会', '小牛血清创新应用研讨会', 3, 26, '线上', '2026-05-11 11:30:00', '2026-05-11 14:30:00', '北京XXXX有限公司', '待监管', 'admin', NOW() FROM biz_project WHERE project_no='ZH-2026-658' UNION ALL
SELECT '5643145674', project_id, 'ZH-2026-659', '整合医学学会项目评审会', '整合医学学会项目评审会', 18, 18, '线下', '2026-04-01 09:00:00', '2026-04-01 17:00:00', '北京XXXX有限公司', '监管通过', 'admin', NOW() FROM biz_project WHERE project_no='ZH-2026-659' UNION ALL
SELECT '5643145675', project_id, 'ZH-2026-650', '智慧医院建设项目方案论证会', '智慧医院建设项目方案论证会', 7, 12, '线上+线下', '2026-03-15 14:00:00', '2026-03-15 16:30:00', '北京XXXX有限公司', '待整改', 'admin', NOW() FROM biz_project WHERE project_no='ZH-2026-650' UNION ALL
SELECT '5643145676', project_id, 'ZH-2026-645', '基层医疗改革试点中期评估会', '基层医疗改革试点中期评估会', 14, 20, '线上', '2026-02-01 09:30:00', '2026-02-01 12:00:00', '北京XXXX有限公司', '已结算', 'admin', NOW() FROM biz_project WHERE project_no='ZH-2026-645' UNION ALL
SELECT '5643145677', project_id, 'ZH-2026-640', '数字化医疗转型方案评审会', '数字化医疗转型方案评审会', 15, 22, '线下', '2026-01-10 10:00:00', '2026-01-10 17:00:00', '北京XXXX有限公司', '已结题', 'admin', NOW() FROM biz_project WHERE project_no='ZH-2026-640';
-- 3. biz_expert (9 条)
INSERT INTO biz_expert(name, phone, work_unit, department, title, region, id_card, audit_status, audit_by, audit_time, status, create_by, create_time)
VALUES
('张三', '13534621147', '北京XXXXXXX医院', '呼吸科', '主任医师', '北京', '110101198501011234', '1', 'admin', NOW(), 'Y', 'admin', NOW()),
('李四', '13534621148', '北京XXXXXXX医院', '血液科', '副主任医师', '北京', '110101198801012345', '2', 'admin', NOW(), 'Y', 'admin', NOW()),
('王五', '13534621149', '北京XXXXXXX医院', '心血管科', '主治医师', '上海', '310101198901013456', '1', 'admin', NOW(), 'Y', 'admin', NOW()),
('赵六', '13534621150', '北京XXXXXXX医院', '神经科', '副主任医师', '广东', '440101198701014567', '3', 'admin', NOW(), 'Y', 'admin', NOW()),
('钱七', '13534621151', '北京XXXXXXX医院', '内分泌科', '主任医师', '北京', '110101199001015678', '2', 'admin', NOW(), 'Y', 'admin', NOW()),
('陈教授', '13534621152', '北京XXXXXXX医院', '消化科', '副主任医师', '北京', '110101198601016789', '2', 'admin', NOW(), 'Y', 'admin', NOW()),
('王教授', '13534621153', '北京XXXXXXX医院', '心血管科', '主任医师', '上海', '310101198401017890', '2', 'admin', NOW(), 'Y', 'admin', NOW()),
('李教授', '13534621154', '北京XXXXXXX医院', '神经科', '主治医师', '北京', '110101199201018901', '2', 'admin', NOW(), 'Y', 'admin', NOW()),
('张教授', '13534621155', '北京XXXXXXX医院', '骨科', '主任医师', '北京', '110101198501019012', '3', 'admin', NOW(), 'Y', 'admin', NOW());
-- 4. biz_project_assign (10 条, 关联前 5 个项目和前 5 个执行方单位, 每个项目 2 个执行单位)
INSERT INTO biz_project_assign(project_id, execution_unit_id, execution_unit_name, sessions, amount)
SELECT p.project_id, e.unit_id, e.unit_name, e.sessions, e.amount
FROM biz_project p
JOIN (
SELECT 'ZH-2026-658' AS project_no, 9 AS unit_id, '北京XXXXXXX公司' AS unit_name, 26 AS sessions, 800000.00 AS amount UNION ALL
SELECT 'ZH-2026-658', 10, '上海XXXXXXX公司', 26, 800000.00 UNION ALL
SELECT 'ZH-2026-659', 9, '北京XXXXXXX公司', 18, 700000.00 UNION ALL
SELECT 'ZH-2026-659', 11, '广州XXXXXXX公司', 18, 600000.00 UNION ALL
SELECT 'ZH-2026-650', 10, '上海XXXXXXX公司', 12, 500000.00 UNION ALL
SELECT 'ZH-2026-650', 12, '深圳XXXXXXX公司', 12, 500000.00 UNION ALL
SELECT 'ZH-2026-645', 9, '北京XXXXXXX公司', 20, 900000.00 UNION ALL
SELECT 'ZH-2026-645', 13, '北京XXXXXXX公司', 20, 700000.00 UNION ALL
SELECT 'ZH-2026-640', 10, '上海XXXXXXX公司', 22, 1100000.00 UNION ALL
SELECT 'ZH-2026-640', 14, '北京XXXXXXX公司', 22, 800000.00
) e ON e.project_no = p.project_no;
-- 5. biz_publicity (4 条, 原 biz_announcement, 列名也改了: ann_type→announce_type)
INSERT INTO biz_publicity(title, announce_type, file_url, publish_time, project_id, project_no, project_name, status, create_by, create_time)
SELECT a.title, a.announce_type, NULL, a.publish_time,
(SELECT project_id FROM biz_project WHERE project_no=a.project_no LIMIT 1),
a.project_no,
(SELECT project_name FROM biz_project WHERE project_no=a.project_no LIMIT 1),
'1', a.create_by, a.create_time
FROM (
SELECT '邀请函 2026第2323号 "恺启新生—胃癌 CAR-T 细胞治疗系列会"' AS title, 'invitation' AS announce_type, '2026-05-06 10:00:00' AS publish_time, 'ZH-2026-658' AS project_no, 'admin' AS create_by, NOW() AS create_time UNION ALL
SELECT '会议通知 2026第2277号 北京同仁医院建院140周年学术会议', 'notice', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW() UNION ALL
SELECT '支持函 2026第2278号 项目启动支持', 'support', '2026-05-06 10:00:00', 'ZH-2026-650', 'admin', NOW() UNION ALL
SELECT '会议日程 2026第2279号 整合医学学会项目评审会', 'agenda', '2026-05-06 10:00:00', 'ZH-2026-659', 'admin', NOW()
) a;
+43 -15
View File
@@ -23,9 +23,8 @@ CREATE TABLE biz_project (
is_finished CHAR(1) DEFAULT '0' COMMENT '是否结题 0否 1是',
is_settled CHAR(1) DEFAULT '0' COMMENT '是否结算 0否 1是',
rating_score DECIMAL(3,1) DEFAULT NULL COMMENT '项目评价分数',
org_id BIGINT DEFAULT NULL COMMENT '公司ID (赞助方或执行方, 由 org_type 区分)',
org_name VARCHAR(200) DEFAULT NULL COMMENT '公司名称(冗余)',
org_type VARCHAR(20) DEFAULT 'sponsor' COMMENT '公司类型 sponsor赞助方/executor执行方',
sponsor_admin_user_id BIGINT DEFAULT NULL COMMENT '赞助方负责人用户ID (sys_user.user_id, role_type=sponsor)',
sponsor_admin_user_name VARCHAR(200) DEFAULT NULL COMMENT '赞助方负责人用户名(冗余)',
start_time DATETIME DEFAULT NULL COMMENT '项目开始时间',
end_time DATETIME DEFAULT NULL COMMENT '项目结束时间',
submit_deadline_days INT DEFAULT 0 COMMENT '提交材料截止天数',
@@ -82,18 +81,6 @@ CREATE TABLE biz_project_assign (
KEY idx_assign_unit (execution_unit_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目-执行单位分配表';
DROP TABLE IF EXISTS biz_project_supervisor;
CREATE TABLE biz_project_supervisor (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID',
project_id BIGINT NOT NULL COMMENT '项目ID',
user_id BIGINT NOT NULL COMMENT '监督员(用户)ID',
user_name VARCHAR(50) DEFAULT NULL COMMENT '监督员姓名(冗余)',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_ps_project (project_id),
KEY idx_ps_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目监督员表';
DROP TABLE IF EXISTS biz_project_labor_role;
CREATE TABLE biz_project_labor_role (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID',
@@ -395,6 +382,47 @@ CREATE TABLE biz_labor_voucher (
-- 注: 2026-08-16 重构, 删 biz_user_role_bind 表, 业务角色统一存 sys_user.role_type
-- sys_user 表在 RuoYi 标准 schema 里已含 role_type 列 (admin/leader/manager/doctor/executor/sponsor)
-- ============================================================================
-- 平台协议/隐私政策文章表 (2026-08-16 新增)
-- type: agreement=用户协议, privacy=隐私政策
-- ============================================================================
CREATE TABLE biz_article (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '文章ID',
title VARCHAR(200) NOT NULL COMMENT '标题',
type VARCHAR(20) NOT NULL COMMENT '类型 agreement=用户协议 privacy=隐私政策',
content MEDIUMTEXT NOT NULL COMMENT '正文 (HTML/富文本)',
status CHAR(1) DEFAULT '0' COMMENT '状态 0启用 1停用',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_biz_article_type (type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台协议及隐私政策文章表';
-- ============================================================================
-- 七大专项计划表 (2026-08-16 新增)
-- 首页 "七大专项计划" 区块改由 admin 后台维护
-- content_type: rich=富文本 / file=上传文件 (PDF/PNG)
-- ============================================================================
CREATE TABLE biz_special_plan (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '专项计划ID',
title VARCHAR(200) NOT NULL COMMENT '专项计划名称',
content_type VARCHAR(20) NOT NULL DEFAULT 'rich' COMMENT '内容类型 rich=富文本 file=上传文件',
content MEDIUMTEXT DEFAULT NULL COMMENT '富文本内容',
file_url VARCHAR(500) DEFAULT NULL COMMENT '上传文件URL (PDF/PNG)',
sort_order INT DEFAULT 0 COMMENT '排序, 从小到大',
status CHAR(1) DEFAULT '0' COMMENT '状态 0启用 1停用',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_special_plan_status (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='七大专项计划表';
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================================
+27
View File
@@ -0,0 +1,27 @@
-- ============================================================================
-- 平台协议/隐私政策文章表 (2026-08-16 新增)
-- type: agreement=用户协议, privacy=隐私政策
-- ============================================================================
CREATE TABLE IF NOT EXISTS biz_article (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '文章ID',
title VARCHAR(200) NOT NULL COMMENT '标题',
type VARCHAR(20) NOT NULL COMMENT '类型 agreement=用户协议 privacy=隐私政策',
content MEDIUMTEXT NOT NULL COMMENT '正文 (HTML/富文本)',
status CHAR(1) DEFAULT '0' COMMENT '状态 0启用 1停用',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_biz_article_type (type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台协议及隐私政策文章表';
-- 初始数据
INSERT INTO biz_article(title, type, content, status, create_by, create_time, remark) VALUES
('用户服务协议', 'agreement',
'<h2>用户服务协议</h2><p>欢迎使用 BAHIM 项目管理平台。请仔细阅读本协议, 注册即视为同意全部条款。</p><p>1. 用户应保证所提供资料真实有效。</p><p>2. 用户应妥善保管账号密码。</p><p>3. 平台保留最终解释权。</p>',
'0', 'admin', NOW(), '注册页底部 [我已阅读并同意《用户协议》] 链接指向此处'),
('隐私政策', 'privacy',
'<h2>隐私政策</h2><p>BAHIM 平台高度重视用户隐私, 严格按照法律法规要求保护您的个人信息。</p><p>1. 收集信息范围: 注册手机号、姓名、单位名称等业务必要字段。</p><p>2. 信息用途: 仅用于项目协作, 不会用于商业推广或对外披露。</p><p>3. 您的权利: 可随时在账号信息页查看和更正个人信息。</p>',
'0', 'admin', NOW(), '注册页底部 [《隐私政策》] 链接指向此处');
+45
View File
@@ -0,0 +1,45 @@
-- ============================================================================
-- 七大专项计划表 (2026-08-16 新增)
-- 首页 "七大专项计划" 区块改由 admin 后台维护, 内容可选 富文本/上传文件
-- content_type: rich=富文本 / file=上传文件 (PDF/PNG)
-- ============================================================================
CREATE TABLE IF NOT EXISTS biz_special_plan (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '专项计划ID',
title VARCHAR(200) NOT NULL COMMENT '专项计划名称',
content_type VARCHAR(20) NOT NULL DEFAULT 'rich' COMMENT '内容类型 rich=富文本 file=上传文件',
content MEDIUMTEXT DEFAULT NULL COMMENT '富文本内容',
file_url VARCHAR(500) DEFAULT NULL COMMENT '上传文件URL (PDF/PNG)',
sort_order INT DEFAULT 0 COMMENT '排序, 从小到大',
status CHAR(1) DEFAULT '0' COMMENT '状态 0启用 1停用',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_special_plan_status (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='七大专项计划表';
-- 初始化 7 个专项 (标题参考首页原硬编码, sort_order 与首页 01-07 对齐)
INSERT INTO biz_special_plan(title, content_type, content, sort_order, status, create_by, create_time) VALUES
('规范化诊疗及医疗质量提升专项计划', 'rich',
'<h3>《规范化诊疗及医疗质量提升专项计划(2026年)》</h3><p>推进临床路径标准化, 加强诊疗规范培训与质量评估, 建立多学科协作机制, 持续提升医疗服务水平。</p>',
1, '0', 'admin', NOW()),
('科研创新专项行动计划', 'rich',
'<h3>《科研创新专项行动计划(2026-2030年)》</h3><p>支持医学科研创新,推动整合医学研究成果转化与产业化应用,构建协同创新生态。</p>',
2, '0', 'admin', NOW()),
('医疗卫生人才培育专项行动计划', 'rich',
'<h3>《医疗卫生人才培育专项行动计划(2026-2030年)》</h3><p>建立多层次医学人才培养体系,重点加强基层医疗人才与跨学科复合型人才建设。</p>',
3, '0', 'admin', NOW()),
('医院管理及高质量发展促进专项计划', 'rich',
'<h3>《医院管理及高质量发展促进专项计划(2026-2030年)》</h3><p>聚焦医院管理创新与运营效率, 推广现代化管理工具, 促进医疗机构高质量发展。</p>',
4, '0', 'admin', NOW()),
('社会公益与可及性提升专项计划', 'rich',
'<h3>《社会公益与可及性提升专项计划(2026-2030年)》</h3><p>扩大优质医疗资源覆盖, 推动健康知识普及, 提升基层医疗可及性与公平性。</p>',
5, '0', 'admin', NOW()),
('政学协作综合项目专项计划', 'rich',
'<h3>《政学协作综合项目专项计划(2026-2030年)》</h3><p>深化政府、学会、医疗机构三方协作, 推动政策落地与学术成果转化, 打造协同创新示范。</p>',
6, '0', 'admin', NOW()),
('组织建设与内部治理专项计划', 'rich',
'<h3>《组织建设与内部治理专项计划(2026-2030年)》</h3><p>完善学会组织架构与制度体系, 加强内部治理与人才培养, 提升学会综合服务能力。</p>',
7, '0', 'admin', NOW());