This commit is contained in:
那些花儿
2025-04-28 15:32:41 +08:00
parent cb3bcb57bc
commit 12c3afb4a6
42 changed files with 3272 additions and 1627 deletions
+158
View File
@@ -0,0 +1,158 @@
nutz:
application:
name: budwk-v5-mini-plus
mvc:
ignore: ^(.+[.])(jsp|png|gif|jpg|js|css|jspx|jpeg|html|mp3|mp4|ico|svg|vue)$
exclusions: /favicon/*,/assets/*,/druid/*,/upload/*
server:
port: 9090
host: 0.0.0.0
jetty:
contextPath: /
threadpool:
idleTimeout: 60000
minThreads: 10
maxThreads: 200
gzip:
enable: true
page:
403: /error/403.html
404: /error/404.html
500: /error/500.html
#结合ftp使用,或用nginx代理ftp路径
#staticPath: E:/project/NutzWk/upload
#开发模式静态资源
staticPathLocal: E:/project/company/hmc_gbhd/src/main/resources/static
security:
tokenName: token
timeout: 3600
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
# 允许同一账号并发登录,则系统参数 SessionOnlyOne 有效,亦可踢人下线并多了弹框提示功能
isConcurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
isShare: true
redis:
host: 192.168.21.207
port: 6379
timeout: 2000
max_redir: 10
database: 10
maxTotal: 150
pool:
maxTotal: 150
maxIdle: 50
minIdle: 10
#password: test123
mode:
normal
#cluster
#nodes=192.168.6.31:6377,192.168.6.31:6378,192.168.6.28:6377,192.168.6.28:6378,192.168.6.34:6377,192.168.6.34:6378
jdbc:
url: jdbc:mysql://192.168.21.207:3306/hmc_gbhd?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root
password: root
validationQuery: select 1
maxActive: 150
testWhileIdle: true
connectionProperties: druid.stat.slowSqlMillis=2000
defaultAutoCommit: true
xsssql:
ignore:
urls: /platform/cms/content/article/addDo,/platform/sys/app/conf/addDo,/platform/sys/app/conf/editDo
beetl:
RESOURCE:
#本地路径,开发时设置本地路径,便于调试
rootLocal: E:/project/company/hmc_gbhd/src/main/resources/views/
root: views/
DELIMITER_STATEMENT_START: '<!--#'
DELIMITER_STATEMENT_END: '#-->'
FT:
#用法: ${"wizzer",escape}
escape: com.budwk.app.web.commons.ext.beetl.HtmlEscapeFormat
#用法: ${10241024,filesize}
filesize: com.budwk.app.web.commons.ext.beetl.FileSizeFormat
#用法: ${"",html2txt="100"} 截取100字符
html2txt: com.budwk.app.web.commons.ext.beetl.Html2TxtFormat
#用法: ${"",strlen="100"} 截取100字符
strlen: com.budwk.app.web.commons.ext.beetl.StrlenFormat
#==============================================================
#Configure Main Scheduler Properties
#==============================================================
#quartz延迟启动秒数
quartz:
startupDelay: 10
scheduler:
instanceName: defaultScheduler
instanceId: AUTO
#==============================================================
#Skip Check Update
#update:true
#not update:false
#==============================================================
skipUpdateCheck: true
#==============================================================
#Configure JobStore isClustered=启用集群模式
#==============================================================
jobStore:
class: org.quartz.impl.jdbcjobstore.JobStoreTX
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
#driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
#driverDelegateClass: org.quartz.impl.jdbcjobstore.oracle.OracleDelegate
#Other delegates can see: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/tutorial-lesson-09.html
dataSource: myDS
tablePrefix: SYS_QRTZ_
isClustered: false
clusterCheckinInterval: 20000
maxMisfiresToHandleAtATime: 120
misfireThreshold: 120000
txIsolationLevelSerializable: false
#==============================================================
#Configure ThreadPool
#==============================================================
threadPool:
class: org.quartz.simpl.SimpleThreadPool
threadCount: 2
threadPriority: 5
threadsInheritContextClassLoaderOfInitializingThread: true
#============================================================================
# Configure Plugins
#============================================================================
plugin:
triggHistory:
class: org.quartz.plugins.history.LoggingJobHistoryPlugin
shutdownhook:
class: org.quartz.plugins.management.ShutdownHookPlugin
cleanShutdown: true
#============================================================================
# NutDao dataSource
#============================================================================
dataSource:
myDS:
connectionProvider:
class: com.budwk.app.task.conn.NutConnectionProvider
sms:
enabled: false
#腾讯云短信配置
tencent:
secret-id: 1
secret-key: 2
appid: 1 #应用ID
sign: '萌发开源' #短信签名
tpl:
#验证码模板ID
code: 1
#短信通知模板ID
msg: 2
@@ -33,6 +33,11 @@ public class Client_user extends BaseModel implements Serializable {
@Comment("手机号")
private String phone;
@Column
@ColDefine(type = ColType.VARCHAR, width = 18)
@Comment("身份证")
private String idCard;
@Column
@ColDefine(type = ColType.VARCHAR, width = 255)
@Comment("openId")
@@ -13,6 +13,7 @@ import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.dao.util.cri.SqlExpressionGroup;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -22,6 +23,7 @@ import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.GET;
import org.nutz.mvc.annotation.Ok;
import java.time.LocalDateTime;
import java.util.List;
@IocBean
@@ -71,13 +73,25 @@ public class ClientActivityController {
/**
* 报名
*/
@At("/sign")
@At("/sign-up")
@AdaptBy(type = JsonAdaptor.class)
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result sign(ClientActivitySignVO signVO) {
public synchronized Result signUp(ClientActivitySignVO signVO) {
clientActivityService.signUp(signVO);
return Result.success();
}
/**
* 查询报名人员
* 报名人不见得参加活动
*/
@At("/list-signup-user/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result listSignUpUser(Long activityId) {
List<ActUser> list = dao.query(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId).and(ActUser::getSignUpUserId, "=", StpClientUtil.STP.getLoginIdAsLong()));
return Result.success(list);
}
/**
* 查询我报名的活动
*/
@@ -89,11 +103,39 @@ public class ClientActivityController {
}
/**
* 个人、报名人都可以查询
* 查询报名签到信息
*/
@At("/sign-info/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result signInfo(Long activityId) {
SqlExpressionGroup seg = new SqlExpressionGroup();
seg.or(ActUser::getUserId, "=", StpClientUtil.STP.getLoginIdAsLong());
seg.or(ActUser::getSignUpUserId, "=", StpClientUtil.STP.getLoginIdAsLong());
Cnd cnd = Cnd.NEW();
cnd.and(seg);
cnd.and(ActUser::getActivityId, "=", activityId);
ActUser actUser = dao.fetch(ActUser.class, cnd);
return Result.success(actUser);
}
/**
* 签到活动
*/
@At("/sign-in/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result signIn(Long activityId) {
clientActivityService.signIn(activityId);
return Result.success();
}
/**
* 个人签到信息
*/
@At("/sign-in-info/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result signInInfo(Long activityId) {
ActUser actUser = dao.fetch(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId).and(ActUser::getUserId, "=", StpClientUtil.STP.getLoginIdAsLong()));
return Result.success(actUser);
}
@@ -0,0 +1,75 @@
package com.budwk.app.client.module.activity.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.budwk.app.base.result.Result;
import com.budwk.app.client.module.activity.service.ClientItemService;
import com.budwk.app.client.module.activity.vo.ClientActivitySignVO;
import com.budwk.app.client.module.activity.vo.ClientItemSignUpVO;
import com.budwk.app.module.activity.models.ActUser;
import com.budwk.app.module.activity.models.ActivityItem;
import com.budwk.app.module.activity.models.ActivityItemUser;
import com.budwk.app.web.commons.auth.satoken.StpClientUtil;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.adaptor.JsonAdaptor;
import org.nutz.mvc.annotation.AdaptBy;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import java.util.List;
/**
* 项目报名
*/
@IocBean
@At("/client/item")
@Ok("json:full")
public class ClientItemController {
@Inject
private Dao dao;
@Inject
private ClientItemService clientItemService;
@At("/sign-up")
@AdaptBy(type = JsonAdaptor.class)
@SaCheckLogin(type = StpClientUtil.TYPE)
public synchronized Result signUp(ClientItemSignUpVO signVO) {
clientItemService.signUp(signVO);
return Result.success();
}
/**
* 获取项目列表
*/
@At("/list/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result list(Long activityId) {
List<ActivityItem> items = dao.query(ActivityItem.class, Cnd.where(ActivityItem::getActivityId, "=", activityId));
return Result.success(items);
}
/**
* 查询团队成员
*/
@At("/list-team-user/?")
@SaCheckLogin(type = StpClientUtil.TYPE)
public Result listTeamUser(Long activityId){
List<ActUser> list = dao.query(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId).and(ActUser::getIsSignIn, "=", 1));
return Result.success(list);
}
/**
* 查询项目报名人员
*/
@At("/list-signup-user")
@SaCheckLogin
public Result listSignUpUser(Long activityId,Long itemId) {
List<ActivityItemUser> itemUsers = dao.query(ActivityItemUser.class, Cnd.where(ActivityItemUser::getActivityId, "=", activityId).and(ActivityItemUser::getItemId, "=", itemId));
return Result.success(itemUsers);
}
}
@@ -1,6 +1,7 @@
package com.budwk.app.client.module.activity.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.client.module.activity.vo.ClientActivitySignVO;
import com.budwk.app.module.activity.models.Activity;
import org.nutz.lang.util.NutMap;
@@ -13,4 +14,15 @@ public interface ClientActivityService extends BaseService<Activity> {
*/
List<NutMap> listMineAct();
/**
* 报名
*/
void signUp(ClientActivitySignVO signVO);
/**
* 报名签到
*/
void signIn(Long activityId);
}
@@ -0,0 +1,9 @@
package com.budwk.app.client.module.activity.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.client.module.activity.vo.ClientItemSignUpVO;
import com.budwk.app.module.activity.models.ActivityItemUser;
public interface ClientItemService extends BaseService<ActivityItemUser> {
void signUp(ClientItemSignUpVO signVO);
}
@@ -1,15 +1,23 @@
package com.budwk.app.client.module.activity.service.impl;
import com.budwk.app.base.exception.BaseException;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.client.auth.models.Client_user;
import com.budwk.app.client.module.activity.service.ClientActivityService;
import com.budwk.app.client.module.activity.vo.ClientActivitySignVO;
import com.budwk.app.module.activity.models.ActUser;
import com.budwk.app.module.activity.models.Activity;
import com.budwk.app.web.commons.auth.satoken.StpClientUtil;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@IocBean(args = {"refer:dao"})
@@ -33,4 +41,63 @@ public class ClientActivityServiceImpl extends BaseServiceImpl<Activity> impleme
sql.setParam("userId", StpClientUtil.STP.getLoginIdAsLong());
return listMap(sql);
}
@Override
public void signUp(ClientActivitySignVO signVO) {
LocalDateTime now = LocalDateTime.now();
List<ActUser> userList = signVO.getUserList();
// 首先检查报名人员是否已经在用户表中
for (ActUser user : userList) {
Client_user clientUser = dao().fetch(Client_user.class, Cnd.where(Client_user::getIdCard, "=", user.getIdCard()));
if (clientUser == null) {
// 创建用户
clientUser = new Client_user();
clientUser.setUserName(user.getUserName());
clientUser.setSex(user.getSex());
clientUser.setPhone(user.getPhone());
clientUser.setIdCard(user.getIdCard());
dao().insert(clientUser);
} else {
// 验证用户之前是不是已经报名 别人帮报 自己报 管理员报
ActUser hisActUser = dao().fetch(ActUser.class, Cnd.where(ActUser::getUserId, "=", clientUser.getId()).and(ActUser::getActivityId, "=", signVO.getActivityId()));
if (hisActUser != null) {
if (!hisActUser.getSignUpUserId().equals(StpClientUtil.STP.getLoginIdAsLong())) {
throw new BaseException("【" + hisActUser.getUnitName() + "】" + clientUser.getUserName() + "已经报名了,请勿重复报名!");
}
}
}
user.setActivityId(signVO.getActivityId());
user.setUserId(clientUser.getId());
user.setUnitName(signVO.getUnitName());
user.setSignUpContacts(signVO.getContactName());
user.setSignUpMobile(signVO.getContactPhone());
user.setSignUpUserId(StpClientUtil.STP.getLoginIdAsLong());
user.setSignUpTime(now);
}
// delete then insert so easy
// 删除旧的报名人员
dao().clear(ActUser.class, Cnd.where(ActUser::getActivityId, "=", signVO.getActivityId()).and(ActUser::getSignUpUserId, "=", StpClientUtil.STP.getLoginIdAsLong()));
// 插入新的报名人员
dao().insert(userList);
}
@Override
public void signIn(Long activityId) {
ActUser actUser = dao().fetch(ActUser.class, Cnd.where(ActUser::getActivityId, "=", activityId).and(ActUser::getUserId, "=", StpClientUtil.STP.getLoginIdAsLong()));
if (actUser == null) {
throw new BaseException("您未报名该活动");
}
if (actUser.getIsSignIn()) {
throw new BaseException("您已签到,请勿重复签到");
}
// 更新签到信息
actUser.setIsSignIn(true);
actUser.setSignInTime(LocalDateTime.now());
dao().update(actUser, "isSignIn|signInTime");
}
}
@@ -0,0 +1,38 @@
package com.budwk.app.client.module.activity.service.impl;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.client.module.activity.service.ClientItemService;
import com.budwk.app.client.module.activity.vo.ClientItemSignUpVO;
import com.budwk.app.module.activity.models.ActivityItemUser;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.List;
@IocBean(args = {"refer:dao"})
public class ClientItemServiceImpl extends BaseServiceImpl<ActivityItemUser> implements ClientItemService {
public ClientItemServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void signUp(ClientItemSignUpVO signVO) {
// 删除掉之前的
clear(Cnd.where(ActivityItemUser::getActivityId, "=", signVO.getActivityId())
.and(ActivityItemUser::getGroupId, "=", signVO.getGroupId())
.and(ActivityItemUser::getItemId, "=", signVO.getItemId())
);
List<ActivityItemUser> userList = signVO.getUserList();
for (ActivityItemUser itemUser : userList) {
itemUser.setActivityId(signVO.getActivityId());
itemUser.setItemId(signVO.getItemId());
itemUser.setGroupId(signVO.getGroupId());
}
insert(userList);
}
}
@@ -0,0 +1,4 @@
package com.budwk.app.client.module.activity.vo;
public class ClientActivitySignInVO {
}
@@ -11,7 +11,7 @@ import java.util.List;
@Data
public class ClientActivitySignVO {
private String activityId;
private Long activityId;
private String unitName;
@@ -0,0 +1,19 @@
package com.budwk.app.client.module.activity.vo;
import com.budwk.app.module.activity.models.ActivityItemUser;
import lombok.Data;
import java.util.List;
@Data
public class ClientItemSignUpVO {
private Long activityId;
private Long itemId;
private Long groupId;
private List<ActivityItemUser> userList;
}
@@ -87,7 +87,7 @@ public class ActUserController {
// 报名时间
LocalDateTime localDateTime = DateUtil.date().toLocalDateTime();
// 活动id
Long activityId = Long.parseLong(vo.getActivityId());
Long activityId = vo.getActivityId();
List<ActUser> userList = vo.getUserList();
@@ -54,7 +54,7 @@ public class ActivityManageController {
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.manager")
public Result save(Activity activity) {
public Result save(@Param("activity") Activity activity) {
// activityService.insertWith(activity, "items");
activityService.insert(activity);
return Result.success();
@@ -66,7 +66,7 @@ public class ActivityManageController {
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("activity.manager")
public Result update(Activity activity) {
public Result update(@Param("activity")Activity activity) {
activityService.updateIgnoreNull(activity);
@@ -98,6 +98,7 @@ public class ActUser extends BaseModel {
@Column
@Comment("是否签到")
@ColDefine(type = ColType.BOOLEAN)
@Default("0")
private Boolean isSignIn;
@Column
@@ -1,5 +1,6 @@
package com.budwk.app.module.activity.models;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -69,11 +70,21 @@ public class Activity extends BaseModel {
@ColDefine(type = ColType.TEXT)
private String content;
@Comment("承诺书")
@Column
@ColDefine(type = ColType.TEXT)
private String protocol;
@Comment("封面")
@Column
@ColDefine(type = ColType.VARCHAR, width = 500)
private String cover;
@Comment("附件")
@Column
@ColDefine(type = ColType.MYSQL_JSON)
private List<JSONObject> files;
/**
* 项目列表
*/
@@ -36,6 +36,16 @@ public class ActivityItem extends BaseModel {
@ColDefine(type = ColType.VARCHAR, width = 300)
private String rule;
@Comment("图片")
@Column
@ColDefine(type = ColType.VARCHAR, width = 300)
private String img;
@Comment("项目人数")
@Column
@ColDefine(type = ColType.INT)
private Integer num;
@Comment("排序")
@Column
@ColDefine(type = ColType.INT)
@@ -0,0 +1,34 @@
package com.budwk.app.module.activity.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.*;
@Data
@Table("act_item_user")
@Comment("项目报名人员")
@EqualsAndHashCode(callSuper = true)
@TableMeta("{'mysql-charset':'utf8mb4'}")
public class ActivityItemUser extends BaseModel {
@Id
@Comment("ID")
private Long id;
@Column
@Comment("活动ID")
private Long activityId;
@Column
@Comment("项目ID")
private Long itemId;
@Column
@Comment("报名人Id")
private Long userId;
@Column
@Comment("分组ID")
private Long groupId;
}
@@ -3,6 +3,7 @@ package com.budwk.app.web.commons.auth.satoken.aop;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.exception.NotLoginException;
import com.budwk.app.web.commons.auth.satoken.StpClientUtil;
import org.nutz.aop.InterceptorChain;
import org.nutz.aop.MethodInterceptor;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -15,7 +16,7 @@ public class SaCheckAnyLoginInterceptor implements MethodInterceptor {
public void filter(InterceptorChain chain) throws Throwable {
Method method = chain.getCallingMethod();
boolean mini = SaManager.getStpLogic("mini").isLogin();
boolean mini = SaManager.getStpLogic(StpClientUtil.TYPE).isLogin();
boolean web = SaManager.getStpLogic("login").isLogin();
if (mini || web) {
chain.doChain();
@@ -45,7 +45,7 @@ public class UploadController {
@Ok("json")
@SaCheckLogin
//AdaptorErrorContext必须是最后一个参数
public Object file(@Param("Filedata") TempFile tf, HttpServletRequest req, AdaptorErrorContext err) {
public Object file(@Param("file") TempFile tf, HttpServletRequest req, AdaptorErrorContext err) {
try {
if (err != null && err.getAdaptorErr() != null) {
return NutMap.NEW().addv("code", 1).addv("msg", "文件不合法");
@@ -0,0 +1,274 @@
<template>
<div class="upload-container">
<el-upload
class="upload-demo"
ref="upload"
action="/open/file/upload/file"
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:on-success="handleSuccess"
:on-error="handleError"
:on-exceed="handleExceed"
:file-list="internalFileList"
:auto-upload="true"
:limit="limit"
:multiple="multiple"
:accept="accept"
:before-upload="beforeUpload"
:on-change="handleChange"
>
<el-button slot="trigger" size="small" type="primary">选取文件</el-button>
<div slot="tip" class="el-upload__tip">
<!-- <slot name="tip">-->
<!-- 请上传符合要求的文件,单个文件不超过{{ maxSize }}MB-->
<!-- </slot>-->
{{ uploadTips }}
</div>
</el-upload>
</div>
</template>
<script>
module.exports = {
name: 'index',
model: {
prop: 'value',
event: 'change'
},
props: {
// v-model 绑定的值
value: {
type: Array,
default: () => []
},
// 最大上传数量
limit: {
type: Number,
default: 5
},
// 是否支持多选
multiple: {
type: Boolean,
default: true
},
// 接受的文件类型
accept: {
type: String,
default: '.doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.zip,.rar'
},
// 最大文件大小(MB)
maxSize: {
type: Number,
default: 50
}
},
computed: {
uploadTips(){
if (this.$slots.tip) {
return this.$slots.tip[0].text
}
let tips = `请上传符合要求的文件,单个文件不超过${this.maxSize}MB`
if(this.accept){
tips += `,支持格式:${this.accept}`
}
return tips
}
},
data() {
return {
internalFileList: []
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
// 同步外部传入的值到内部状态
this.internalFileList = newVal.map(file => ({
name: file.name,
url: file.url,
uid: file.uid || this.generateUid(),
status: 'success'
}))
}
}
},
methods: {
// 生成唯一ID
generateUid() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
},
// 点击文件列表中已上传的文件时的钩子
handlePreview(file) {
if (file.url) {
// 既然看到这里吧 把预览写了吧
}
},
// 文件列表移除文件
handleRemove(file, fileList) {
this.internalFileList = fileList
this.emitChange(fileList)
this.$emit('file-removed', file, fileList)
},
// 文件删除
beforeRemove(file) {
if(file.status === 'success'){
return this.$confirm(`确定移除 ${file.name}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
}
},
// 文件上传成功时的钩子
handleSuccess(response, file, fileList) {
// 更新文件状态
const index = this.internalFileList.findIndex(f => f.uid === file.uid)
if (index !== -1) {
if(response.code === 0){
debugger
this.$set(this.internalFileList, index, {
// ...file,
// status: 'success',
// ...response.data
name: file.name,
url: response.data?.file_url,
type: response.data?.file_type,
size: response.data?.file_size
})
this.$message.success(`${file.name} 上传成功`)
}else{
this.$message.error(`${file.name} 上传失败`)
}
}
console.log(this.internalFileList)
this.emitChange(this.internalFileList)
this.$emit('upload-success', response, file, fileList)
this.$message.success(`${file.name} 上传成功`)
},
// 文件上传失败时的钩子
handleError(err, file, fileList) {
const index = this.internalFileList.findIndex(f => f.uid === file.uid)
if (index !== -1) {
this.$set(this.internalFileList, index, {
...file,
status: 'fail'
})
}
this.emitChange(this.internalFileList)
this.$emit('upload-error', err, file, fileList)
this.$message.error(`${file.name} 上传失败`)
},
// 文件超出数量限制时的钩子
handleExceed(files, fileList) {
this.$message.warning(
`当前限制选择 ${this.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${
files.length + fileList.length
} 个文件`
)
},
// 上传前的校验
beforeUpload(file) {
const isLt = file.size / 1024 / 1024 < this.maxSize
if (!isLt) {
this.$message.error(`上传文件大小不能超过 ${this.maxSize}MB!`)
return false
}
const isValid = this.validateFileType(file)
if(!isValid){
this.$message.error(`只能上传 ${this.accept} 格式的文件`)
return false
}
return isValid
},
validateFileType(file){
const allowedExtensions = this.accept.split(',').map(ext => ext.trim().toLowerCase()).filter(ext => ext.startsWith('.'))
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
if (allowedExtensions.includes(fileExtension)) {
return true
}
return false
},
// 文件状态改变
handleChange(file, fileList) {
this.internalFileList = fileList
// 如果是新增文件但还未上传成功,不触发change事件
// if (file.status !== 'ready') {
// this.emitChange(fileList)
// }
if(file.status === 'ready'){
const isValid = this.validateFileType(file)
if (!isValid) {
// 从文件列表中移除不符合的文件
this.internalFileList = fileList.filter(f => f.uid !== file.uid)
return false
}
}
this.internalFileList = fileList
return true
},
// 触发v-model更新
emitChange(fileList) {
// 格式化输出数据,只保留必要信息
debugger
const formattedList = fileList.map(file => ({
name: file.name,
url: file.url,
type: file.type,
size: file.size
}))
this.$emit('change', formattedList)
},
// 手动清空文件列表
clearFiles() {
this.$refs.upload.clearFiles()
this.internalFileList = []
this.emitChange([])
},
// 获取当前文件列表
getFileList() {
return this.internalFileList
},
// 手动提交上传
submit() {
this.$refs.upload.submit()
}
}
}
</script>
<style scoped>
.upload-container {
padding: 20px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
background-color: #fbfdff;
}
.upload-container:hover {
border-color: #409eff;
}
.el-upload__tip {
margin-top: 10px;
color: #606266;
font-size: 12px;
}
</style>
@@ -89,6 +89,7 @@ module.exports = {
handleSuccess(response, file, fileList) {
if (response.code === 0) {
if (this.maxCount === 1) {
debugger
this.$emit('input', response.data)
} else {
@@ -122,9 +123,5 @@ module.exports = {
</script>
<style scoped>
.el-upload--picture-card {
//width: 100px;
//height: 100px;
//line-height: 100px;
}
</style>
@@ -57,6 +57,7 @@
Vue.component("table-tool", httpVueLoader("/components/plugins/sysTableTool/index.vue?v=" + new Date().getTime()))
Vue.component("text-editor", httpVueLoader("/components/plugins/textEditor/index.vue?v=" + new Date().getTime()))
Vue.component("image-upload", httpVueLoader("/components/plugins/imageUpload/index.vue?v=" + new Date().getTime()))
Vue.component("file-upload", httpVueLoader("/components/plugins/fileUpload/index.vue?v=" + new Date().getTime()))
</script>
</head>
<body class="hold-transition sidebar-mini layout-fixed">
@@ -55,6 +55,13 @@ const addOrUpdate = {
<el-form-item label="活动内容" prop="content">
<text-editor v-model="form.content" :height="200"></text-editor>
</el-form-item>
<el-form-item label="承诺书" prop="protocol">
<text-editor v-model="form.protocol" :height="200"></text-editor>
</el-form-item>
<el-form-item label="活动附件" prop="files">
{{form.files}}
<file-upload v-model="form.files" accept=".pdf"></file-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -91,7 +98,8 @@ const addOrUpdate = {
hostUnit: [{required: true, message: '请输入主办单位', trigger: ['blur', 'change']}],
coHostUnit: [{required: true, message: '请输入承办单位', trigger: ['blur', 'change']}],
contact: [{required: true, message: '请输入联系人', trigger: ['blur', 'change']}],
content: [{required: true, message: '请输入活动内容', trigger: ['blur', 'change']}]
content: [{required: true, message: '请输入活动内容', trigger: ['blur', 'change']}],
protocol: [{required: true, message: '请输入承诺书', trigger: ['blur', 'change']}],
},
stepActive: 0
}
@@ -117,7 +125,7 @@ const addOrUpdate = {
confirmButtonText: '确定',
cancelButtonText: '取消'
}).then(() => {
$.post('/platform/activity/manage/' + (this.form.id ? 'update' : 'save'), this.form).then(res => {
$.post('/platform/activity/manage/' + (this.form.id ? 'update' : 'save'), {"activity": JSON.stringify(this.form)}).then(res => {
if (res.code === 0) {
this.$message.success(this.form.id ? '修改成功' : '新增成功')
this.$emit('refresh')
@@ -20,6 +20,12 @@ const itemForm = {
<el-input v-model="row.rule" placeholder="请输入评分规则"></el-input>
</template>
</el-table-column>
<el-table-column label="图片">
<template #default="{row}">
{{row.img}}
<image-upload v-model="row.img" :max-count="1"></image-upload>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100px">
<template #default="{$index}">
<el-button type="text" class="text-danger" @click="removeRow($index)">删除</el-button>
+15
View File
@@ -50,4 +50,19 @@ uni-page-body{
height: 100%;
}
/* 隐藏所有页面的滚动条(包括 web 和 小程序) */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
/* 针对 H5 的额外设置 */
page, html, body {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
</style>
+25 -141
View File
@@ -1,27 +1,14 @@
<template>
<!-- <div class="scanCode" v-if="isScanning">-->
<!-- <div class="btn-container">-->
<!-- <div class="left-back" @click.stop="handleBack">-->
<!-- <t-icon name="arrow-left" />-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="container">-->
<!-- <div class="qrcode">-->
<!-- <div ref="qrCodeElement" id="reader"></div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="page-box" v-if="isScanning">-->
<!--&lt;!&ndash; <div class="btn-box">&ndash;&gt;-->
<!--&lt;!&ndash; <div>&ndash;&gt;-->
<!--&lt;!&ndash; <t-icon name="arrow-left" @click="handleBack"/>&ndash;&gt;-->
<!--&lt;!&ndash; </div>&ndash;&gt;-->
<!--&lt;!&ndash; </div>&ndash;&gt;-->
<!-- <div id="reader"></div>-->
<!-- </div>-->
<div class="page-box" v-if="isScanning">
<div class="btn-box">
<div>
<t-icon name="arrow-left" @click="handleBack"/>
</div>
</div>
<div class="qr-container">
<div class="qr-box">
<div id="reader"></div>
</div>
</div>
<div class="container" v-show="isScanning">
<div id="reader"></div>
</div>
</template>
@@ -32,10 +19,8 @@ import {Toast} from "tdesign-mobile-vue";
const emit = defineEmits(['scan-success', 'back'])
const html5QrCode = ref(null)
const fileList = ref([])
const isScanning = ref(false)
const hasCameraPermission = ref(false)
const qrCodeElement = ref(null)
const startScan = async () => {
try {
@@ -45,50 +30,35 @@ const startScan = async () => {
return
}
// 2. 显示扫描界面
isScanning.value = true
// 3. 等待DOM更新完成
await nextTick()
console.log(document.getElementById('reader'))
// 5. 初始化扫码器
html5QrCode.value = new Html5Qrcode('reader')
console.log(html5QrCode.value)
// 6. 开始扫描
await html5QrCode.value.start(
{facingMode: 'environment'},
{fps: 10, qrbox: {width: 200, height: 200}},
{
fps: 10,
qrbox: {width: 250, height: 250},
aspectRatio: 2
},
(decodedText) => {
stopScan()
emit('scan-success', decodedText)
}
)
} catch (err) {
isScanning.value = false
handleCameraError(err)
}
}
// 开始摄像头扫描
const startCameraScan = () => {
return html5QrCode.value.start(
{facingMode: 'environment'},
{
fps: 1,
qrbox: {width: 250, height: 250}
},
(decodedText) => {
emit('scan-success', decodedText)
}
)
}
// 停止扫描
const stopScan = async () => {
alert(2)
isScanning.value = false
if (html5QrCode.value?.isScanning) {
try {
@@ -102,7 +72,6 @@ const stopScan = async () => {
// 处理摄像头错误
const handleCameraError = (err) => {
alert(JSON.stringify(err))
let message = '摄像头启动失败'
if (err.name === 'NotAllowedError') {
message = '请允许摄像头访问权限'
@@ -132,106 +101,21 @@ defineExpose({
</script>
<style scoped lang="less">
//.scanCode {
// //background: rgba(0, 0, 0);
// position: fixed;
// left: 0;
// right: 0;
// top: 0;
// bottom: 0;
// z-index: 999;
//}
//
//.btn-container {
// position: absolute;
// top: 16px;
// left: 16px;
// z-index: 1000;
//}
//
//.left-back {
// color: #fff;
// font-size: 24px;
// padding: 8px;
// background: rgba(0, 0, 0, 0.3);
// border-radius: 50%;
// display: flex;
// align-items: center;
// justify-content: center;
// width: 40px;
// height: 40px;
//}
//
//.container {
// //height: 100vh;
// //position: relative;
// //width: 100%;
// position: fixed;
// left: 0;
// right: 0;
// top: 0;
// bottom: 0;
// z-index: 999;
//}
//
//.qrcode {
// height: 100%;
// position: fixed;
// left: 0;
// right: 0;
// top: 0;
// bottom: 0;
// z-index: 999;
//}
//#reader {
// //top: 20%;
// //left: 0;
// //transform: translateY(-50%);
// //height: 100%;
// width: 500rpx;
// height: 500rpx;
//}
.page-box {
//display: flex;
//flex-direction: column;
//height: 100%;
background: #000;
.container {
position: fixed;
left: 0;
right: 0;
top: 0;
left: 0;
bottom: 0;
right: 0;
height: 100vh;
width: 100%;
background: rgba(#000000,0.48);
z-index: 999;
}
.qr-container {
position: relative;
width: 100%;
height: 100%;
}
.qr-box {
height: 100%;
}
#reader {
top: 50%;
height: 100vh;
//top: 50%;
left: 0;
}
.btn-box {
//flex: 1;
//display: flex;
//justify-content: space-around;
//align-items: flex-start;
//margin-top: auto;
//padding: 12px;
//color: #fff;
//font-size: 28px;
position: a;
}
//transform: translateY(-50%);
}
</style>
+21 -4
View File
@@ -10,8 +10,7 @@
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"disableScroll": true
"navigationStyle": "custom"
}
},
{
@@ -26,12 +25,24 @@
"navigationBarTitleText": "活动详情"
}
},
{
"path": "pages/activity/detail/detail2",
"style": {
"navigationBarTitleText": "活动详情"
}
},
{
"path": "pages/activity/registration-form/index",
"style": {
"navigationBarTitleText": "活动报名"
}
},
{
"path": "pages/activity/registration-form/success",
"style": {
"navigationBarTitleText": "报名成功"
}
},
{
"path": "pages/activity/team/index",
"style": {
@@ -49,12 +60,18 @@
"style": {
"navigationBarTitleText": "扫码签到"
}
},
{
"path": "pages/activity/team-form/index",
"style": {
"navigationBarTitleText": "项目报名"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "#FFFFFF",
"navigationBarTextStyle": "#000000",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#f45a5a",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
@@ -0,0 +1,80 @@
<template>
<view class="page-container">
<view style="position:relative;width: 100%;height: 400rpx;overflow: hidden;margin-bottom: 0rpx">
<image :src="actData.cover" mode="aspectFill" style="width: 100%"></image>
</view>
</view>
</template>
<script setup>
import {onLoad, onPageScroll} from "@dcloudio/uni-app";
import {Toast} from "tdesign-mobile-vue";
import {ref} from 'vue'
import {useRequest} from "@/utils/request";
import moment from "moment/moment";
const request = useRequest()
const actId = ref('');
const actData = ref({});
const signUpInfo = ref({})
onLoad((option) => {
if (option.id) {
actId.value = option.id;
getDetailData(option.id);
getSignUpInfo(option.id);
} else {
Toast({message: 'ID不存在', theme: 'error'});
}
});
// 获取详情
const getDetailData = async (id) => {
const loading = Toast({
theme: 'loading',
duration: 0
});
try {
const {code, data} = await request.get('/client/activity/get/' + id)
if (code === 0) {
actData.value = data;
// 如果活动未开始,开始倒计时
// if (activityStatus.value === 'notStarted') {
// startCountdown();
// }
} else {
Toast({message: '获取详情失败', theme: 'error'});
}
} catch (error) {
console.error('获取详情失败', error);
Toast({message: '获取详情失败', theme: 'error'});
} finally {
loading.clear()
}
};
// 查询报名信息
const getSignUpInfo = async () => {
try {
const {code, data} = await request.get('/client/activity/sign-info/' + actId.value);
if (code === 0 && data) {
signUpInfo.value = data;
} else {
Toast({message: '查询报名信息失败', theme: 'error'});
}
} catch (e) {
console.error(e)
}
}
</script>
<style scoped lang="less">
.page-container {
position: relative;
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,11 +0,0 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>
+58 -197
View File
@@ -1,6 +1,5 @@
<template>
<view class="page-container">
<!-- 顶部搜索和筛选 -->
<view class="header">
<view class="search-container">
<t-search
@@ -8,65 +7,66 @@
placeholder="搜索活动名称"
shape="round"
class="search-input"
@search="handleSearch"
@onSearch="handleSearch"
/>
</view>
<!-- 状态筛选标签组 -->
<view class="filter-tabs">
<scroll-view scroll-x class="tabs-scroll-view" :show-scrollbar="false">
<view class="tabs-container">
<view
v-for="option in statusOptions"
:key="option.value"
class="filter-tab"
:class="{ active: pageForm.status === option.value }"
@click="selectStatusFilter(option.value)"
>
{{ option.label }}
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 活动列表 - 使用scroll-view实现 -->
<scroll-view
scroll-y
class="list-scroll-view"
@scrolltolower="handleScrollToBottom"
refresher-enabled
:refresher-triggered="refresherTriggered"
@refresherrefresh="handleRefresh"
refresher-background="#f2f3f5"
<scroll-view
scroll-y
class="list-scroll-view"
@scrolltolower="handleScrollToBottom"
refresher-enabled
:refresher-triggered="refresherTriggered"
@refresherrefresh="handleRefresh"
refresher-background="#f2f3f5"
>
<view class="list-container">
<view
class="activity-card"
class="card-item"
v-for="item in dataList"
:key="item.id"
@click="navigateToDetail(item.id)"
>
<view class="image-container">
<image :src="item.cover" mode="aspectFill" class="activity-image"/>
<view class="activity-time">
{{ formatTimeRange(item.startTime, item.endTime) }}
</view>
<view class="status-tag-container">
<t-tag
:theme="getTagTheme(getActivityStatus(item))"
size="medium"
shape="mark"
>{{ getStatusText(getActivityStatus(item)) }}
</t-tag>
</view>
</view>
<view class="activity-info">
<text class="activity-title">{{ item.title }}</text>
<view class="activity-address">
<t-icon name="location" size="small"/>
<text>{{ item.address || '暂无地址信息' }}</text>
<view>
<view style="display: flex;align-items: center;margin-bottom: 10px">
<image
src="https://assets.cumen.fun/upload/017cdaf1-c5fa-788e-eea2-d0ee249f902b/0187376a-3cfa-07b3-3cd6-7479bf463865?x-oss-process=style/720-jpg"
style="width: 30px;height: 30px;flex-shrink: 0"
></image>
<view
style="margin-left: 10px;font-weight: 600;white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ item.title }}
</view>
</view>
<view style="display: flex;">
<image :src="item.cover" mode="aspectFill" style="flex-shrink:0;width: 120px;height: 90px;border-radius: 5px;"></image>
<view
style="margin-left:10px;flex:1;overflow:hidden;display: flex;flex-direction: column;justify-content:space-around;color: #534d4d;">
<view
style="display: flex; align-items: center; column-gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<t-icon name="time"></t-icon>
{{ moment(item.startTime).format('MM-DD') }}
</view>
<view style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<t-icon name="location"></t-icon>
{{ item.address }}
</view>
<view style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<t-icon name="castle-7"></t-icon>
{{ item.address }}
</view>
</view>
</view>
<view style="border-top: 1px solid #f0f0f0;margin-top: 10px;padding-top: 12px;display: flex;align-items: center;justify-content: space-between">
<view>
100人已报名
</view>
<!-- <image src="https://assets.cumen.fun/upload/017cdaf1-c5fa-788e-eea2-d0ee249f902b/01892580-5db0-be0b-5643-35d6c5a83591"-->
<!-- style="width: 105px;height: 40px"-->
<!-- ></image>-->
<t-button theme="primary" shape="round" size="small">立即报名</t-button>
</view>
</view>
</view>
@@ -89,8 +89,6 @@
<script setup>
import {ref, reactive, onMounted} from 'vue';
// 移除不再需要的onReachBottom和onPullDownRefresh
// import {onReachBottom, onPullDownRefresh} from "@dcloudio/uni-app";
import {Toast} from 'tdesign-mobile-vue';
import {useRequest} from "@/utils/request";
import moment from "moment";
@@ -118,57 +116,8 @@ const pageForm = reactive({
const dataList = ref([]);
const loading = ref(false);
const hasMore = ref(true);
// 添加下拉刷新状态
const refresherTriggered = ref(false);
// 获取活动状态
const getActivityStatus = (item) => {
const now = moment();
const signStartTime = moment(item.signStartTime);
const signEndTime = moment(item.signEndTime);
const activityStartTime = moment(item.startTime);
const activityEndTime = moment(item.endTime);
if (now.isBefore(signStartTime)) {
return 'notStartSign'; // 未开始报名
} else if (now.isBetween(signStartTime, signEndTime)) {
return 'signing'; // 报名中
} else if (now.isBetween(signEndTime, activityStartTime)) {
return 'notStartActivity'; // 活动未开始
} else if (now.isBetween(activityStartTime, activityEndTime)) {
return 'activitying'; // 活动进行中
} else {
return 'ended'; // 已结束
}
};
// 获取状态文本
const getStatusText = (status) => {
return {
'notStartSign': '未开始报名',
'signing': '报名中',
'notStartActivity': '活动未开始',
'activitying': '活动进行中',
'ended': '已结束'
}[status] || '未知状态';
};
// 获取标签主题
const getTagTheme = (status) => {
return {
'notStartSign': 'primary',
'signing': 'success',
'notStartActivity': 'warning',
'activitying': 'info',
'ended': 'default'
}[status] || 'default';
};
// 格式化时间范围
const formatTimeRange = (startTime, endTime) => {
return `${moment(startTime).format('MM-DD')} ~ ${moment(endTime).format('MM-DD')}`;
};
// 选择状态筛选
const selectStatusFilter = (status) => {
pageForm.status = status;
@@ -221,13 +170,13 @@ const getListData = async (isRefresh = false) => {
}
};
// scroll-view 下拉刷新
//下拉刷新
const handleRefresh = () => {
if (loading.value) {
refresherTriggered.value = false;
return;
}
refresherTriggered.value = true;
pageForm.pageNumber = 1;
getListData(true);
@@ -241,7 +190,7 @@ const handleSearch = () => {
getListData(true);
};
// scroll-view 触底加载更多
// 触底加载更多
const handleScrollToBottom = () => {
if (loading.value || !hasMore.value) return;
getListData();
@@ -276,38 +225,6 @@ onMounted(() => {
width: 100%;
}
.filter-tabs {
margin-top: 20rpx;
padding: 0 12rpx;
}
.tabs-scroll-view {
white-space: nowrap;
width: 100%;
}
.tabs-container {
display: inline-flex;
padding: 0 12rpx;
}
.filter-tab {
display: inline-block;
padding: 10rpx 24rpx;
margin: 0 12rpx;
font-size: 26rpx;
color: #666;
background-color: #f5f5f5;
border-radius: 48rpx;
transition: all 0.3s;
&.active {
color: #fff;
background-color: var(--td-brand-color);
font-weight: 500;
}
}
.list-scroll-view {
flex: 1;
height: 0;
@@ -319,82 +236,26 @@ onMounted(() => {
padding: 20rpx 24rpx;
}
.activity-card {
.card-item {
width: 100%;
margin-bottom: 24rpx;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
transition: transform 0.3s;
//background: url("https://assets.cumen.fun/upload/017cdaf1-c5fa-788e-eea2-d0ee249f902b/0189257e-fc75-5b89-b900-104c706f2d63") top / 100% auto no-repeat,
//linear-gradient(to right, white, #ecb5b5);
//background: no-repeat, linear-gradient(to right, white, #f6d4d4);
background: #ffffff;
&:active {
transform: scale(0.98);
}
}
.image-container {
position: relative;
height: 360rpx;
overflow: hidden;
}
.activity-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s;
.activity-card:hover & {
transform: scale(1.05);
}
}
.activity-time {
position: absolute;
left: 16rpx;
bottom: 16rpx;
padding: 6rpx 16rpx;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 22rpx;
border-radius: 6rpx;
}
.activity-info {
padding: 20rpx 24rpx 10rpx;
}
.activity-title {
width: 100%;
font-size: 32rpx;
font-weight: 500;
color: #333;
line-height: 1.4;
display: block;
}
.activity-address {
display: flex;
align-items: center;
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
}
.activity-address :deep(.t-icon) {
margin-right: 8rpx;
color: #999;
}
.status-tag-container {
position: absolute;
right: 0;
top: 16rpx;
}
.loading-container {
padding: 40rpx 0;
text-align: center;
}
</style>
@@ -1,13 +1,13 @@
<template>
<view class="page-container">
<!-- <view class="act-info">-->
<!-- <view class="act-title">奋进新征程 运动促健康 2024晴江第六届"牧城之夜"荧光跑</view>-->
<!-- <view class="act-meta">-->
<!-- <view class="act-meta-item">22222</view>-->
<!-- <view class="act-meta-item">22222222222222222222</view>-->
<!-- </view>-->
<!-- </view>-->
<!-- <view class="act-info">-->
<!-- <view class="act-title">奋进新征程 运动促健康 2024晴江第六届"牧城之夜"荧光跑</view>-->
<!-- <view class="act-meta">-->
<!-- <view class="act-meta-item">22222</view>-->
<!-- <view class="act-meta-item">22222222222222222222</view>-->
<!-- </view>-->
<!-- </view>-->
<view class="form-area">
<t-form
@@ -44,7 +44,7 @@
<view class="user-card-header-title">
<span>{{ index + 1 }}</span>
</view>
<view class="remove" @click="formData.userList.splice(index, 1)">
<view class="remove" @click="removeUser(index)">
<t-icon name="delete" size="18px"></t-icon>
</view>
</view>
@@ -59,7 +59,8 @@
<t-radio :block="false" label="女" value="女"/>
</t-radio-group>
</t-form-item>
<t-form-item label="职务" :name="`userList[${index}].position`" :rules="[{required:true,message: '请输入职务'}]">
<t-form-item label="职务" :name="`userList[${index}].position`"
:rules="[{required:true,message: '请输入职务'}]">
<t-input v-model="user.position" borderless placeholder="请输入职务"></t-input>
</t-form-item>
<t-form-item label="身份证号" :name="`userList[${index}].idCard`"
@@ -74,7 +75,7 @@
:rules="[{required:true,message: '请选择是否住宿'}]">
<t-radio-group v-model="user.isStay" class="radio-box">
<t-radio :block="false" name="radio" value="1" label="是"/>
<t-radio :block="false" name="radio" value="2" label="否"/>
<t-radio :block="false" name="radio" value="0" label="否"/>
</t-radio-group>
</t-form-item>
</view>
@@ -109,36 +110,141 @@
<script setup>
import {ref} from 'vue'
import {useRequest} from '@/utils/request';
import {Toast} from "tdesign-mobile-vue";
import {Dialog, Toast} from "tdesign-mobile-vue";
import {onLoad} from "@dcloudio/uni-app";
const request = useRequest()
const actId = ref(null)
const agreementChecked = ref(false)
const formRef = ref(null)
const formData = ref({
unitName: null,
userList: []
unitName: "测试",
contactName: "测试",
contactPhone: "18899998888",
userList: [{
userName: "测试",
sex: "男",
position: "测试",
idCard: "421087200004040010",
phone: "18899998888",
isStay: "1"
}]
})
onLoad((option) => {
if (option.id) {
actId.value = option.id
formData.value.activityId = option.id
listSignUpUser()
} else {
Toast({message: 'ID不存在', theme: 'error'});
}
})
// 查询报名人员信息
async function listSignUpUser() {
try {
const {code, data} = await request.get('/client/activity/list-signup-user/' + actId.value)
if (code === 0 && data) {
if (data.length > 0) {
formData.value.userList = data
}
} else {
Toast({message: '查询报名人员信息失败', theme: 'error'});
}
} catch (e) {
console.error(e)
}
}
// 表单验证
async function validaForm() {
return new Promise((resolve, reject) => {
formRef.value.validate().then(async (valid) => {
resolve(valid === true)
}).catch((error) => {
reject(error)
})
})
}
// 承诺书
async function validateAgreement() {
return new Promise((resolve, reject) => {
if (agreementChecked.value) {
resolve(true)
return
}
Dialog.confirm({
title: '提示',
content: '参赛条款与免责声明:阅读并同意本承诺书内容,点击"确定并报名"即表示您已充分理解并接受所有条款,承担相应法律责任。',
confirmBtn: '确定并报名',
cancelBtn: '取消',
onConfirm: () => {
agreementChecked.value = true
resolve(true)
},
onCancel: () => {
agreementChecked.value = false
resolve(false)
}
})
})
}
// 提交表单
async function onSubmit() {
console.log(formRef)
console.log(formRef.value)
formRef.value.validate()
try{
const {code,data} = await request.post('/client/activity/sign', formData.value)
const formRes = await validaForm()
if (!formRes) return
const agreementRes = await validateAgreement()
if (!agreementRes) return
// 提交表单
await submitForm()
}
// 报名
async function submitForm() {
uni.showLoading({
title: '提交中...',
mask: true,
})
try {
const {code, data, msg} = await request.post('/client/activity/sign-up', formData.value)
if (code === 0) {
Toast.success('报名成功')
// setTimeout(() => {
// uni.navigateBack()
// }, 1500)
}else{
Toast.error(data.msg)
setTimeout(() => {
uni.navigateTo({
url: '/pages/activity/registration-form/success?id=' + actId.value
})
}, 500)
} else {
Dialog.alert({
title: '报名失败',
content: msg,
confirmBtn: '确定'
})
}
}catch (e) {
} catch (e) {
console.log(e)
Toast.error(e.message)
} finally {
uni.hideLoading()
}
}
// 移除人员
async function removeUser(index) {
if (formData.value.userList.length === 1) {
Toast('至少需要一个报名人员')
return
}
formData.value.userList.splice(index, 1)
}
</script>
<style scoped lang="less">
@@ -185,7 +291,7 @@ async function onSubmit() {
}
}
.form-area{
.form-area {
flex: 1;
overflow-y: auto;
//margin-top: 100px;
@@ -0,0 +1,188 @@
<template>
<view class="page-container">
<!-- 成功提示区域 -->
<view class="success-content">
<!-- 成功图片 -->
<view class="success-image">
<image src="https://cdn-icons-png.flaticon.com/512/10613/10613684.png" mode="aspectFit"></image>
</view>
<!-- 成功信息 -->
<view class="success-info">
<view class="success-title">报名成功</view>
<view class="success-subtitle">您已成功报名参加"{{ activityTitle }}"</view>
</view>
<!-- 小提示 -->
<view class="success-tips">
<t-icon name="info-circle-filled" size="16px" />
<text>活动开始前我们将通过短信通知您</text>
</view>
<!-- 底部按钮 -->
<view class="action-buttons">
<t-button theme="default" shape="round" @click="navigateToHome">返回首页</t-button>
<t-button theme="primary" shape="round" @click="viewRegistrationDetails">查看报名详情</t-button>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { Toast } from 'tdesign-mobile-vue';
import { useRequest } from '@/utils/request';
const request = useRequest();
const actId = ref(null);
const activityTitle = ref('活动名称');
// 获取路由参数
onLoad((option) => {
if (option.id) {
actId.value = option.id;
// 获取活动信息和标题
getActivityInfo(option.id);
} else {
Toast({message: 'ID不存在', theme: 'error'});
}
});
// 获取活动信息(只获取标题)
const getActivityInfo = async (id) => {
try {
const {code, data} = await request.get('/client/activity/get/' + id);
if (code === 0 && data) {
activityTitle.value = data.title;
}
} catch (error) {
console.error('获取活动信息失败', error);
}
};
// 返回首页
const navigateToHome = () => {
uni.switchTab({
url: '/pages/index/index'
});
};
// 查看报名详情
const viewRegistrationDetails = () => {
uni.navigateTo({
url: '/pages/activity/registration-details/index?id=' + actId.value
});
};
</script>
<style scoped lang="less">
.page-container {
display: flex;
flex-direction: column;
justify-content: space-between;
padding-bottom: 50px;
background-color: #fff;
min-height: 100vh;
}
/* 成功内容区域 */
.success-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
/* 成功图片 */
.success-image {
margin-bottom: 30px;
width: 200px;
height: 200px;
image {
width: 100%;
height: 100%;
animation: float 3s ease-in-out infinite;
}
}
@keyframes float {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
100% {
transform: translateY(0px);
}
}
/* 成功信息 */
.success-info {
position: relative;
margin-bottom: 30px;
}
.success-title {
color: #333;
font-size: 28px;
font-weight: bold;
margin-bottom: 10px;
}
.success-subtitle {
color: #666;
font-size: 16px;
margin-bottom: 20px;
}
/* 成功提示 */
.success-tips {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 95, 92, 0.1);
border-radius: 100px;
padding: 10px 20px;
margin-top: 20px;
margin-bottom: 40px;
.t-icon {
color: #ff5f5c;
margin-right: 6px;
}
text {
color: #666;
font-size: 14px;
}
}
/* 按钮区域 */
.action-buttons {
display: flex;
gap: 12px;
width: 100%;
padding: 0 20px;
margin-top: 20px;
:deep(.t-button) {
flex: 1;
}
:deep(.t-button--primary) {
background-color: #ff5f5c !important;
border-color: #ff5f5c !important;
}
:deep(.t-button--default) {
border-color: #ff5f5c !important;
color: #ff5f5c !important;
}
}
</style>
+397 -30
View File
@@ -1,53 +1,60 @@
<template>
<view class="page-container">
<t-dropdown-menu>
<t-dropdown-menu class="top-dropdown">
<t-dropdown-item :options="actOptions" :value="selectActId" @change="getSignInfo"/>
</t-dropdown-menu>
<view class="content-container">
<view class="scan-area" @click="handleScan">
<view class="scan-icon-wrapper">
<view class="scan-area animate__animated animate__fadeIn" @click="handleScan">
<view class="scan-icon-wrapper pulse-animation">
<t-icon name="scan" size="80" color="#f45a5a"></t-icon>
<view class="scan-circles"></view>
</view>
<t-button shape="round"
theme="primary"
size="medium">扫描二维码签到
size="medium"
class="scan-button">扫描二维码签到
</t-button>
</view>
<view class="guide-area">
<view class="guide-area animate__animated animate__fadeInUp">
<view class="guide-title">签到说明</view>
<view class="guide-list">
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<view class="guide-item animate__animated animate__fadeInLeft" style="animation-delay: 0.1s">
<t-icon name="notification" size="16" color="#ff5f5c" class="guide-icon"></t-icon>
<text class="guide-text">请对准活动二维码进行扫描</text>
</view>
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<view class="guide-item animate__animated animate__fadeInLeft" style="animation-delay: 0.2s">
<t-icon name="notification" size="16" color="#ff5f5c" class="guide-icon"></t-icon>
<text class="guide-text">确保手机相机权限已开启</text>
</view>
<view class="guide-item">
<uni-icons class="guide-icon" type="info" size="16" color="#999999"/>
<view class="guide-item animate__animated animate__fadeInLeft" style="animation-delay: 0.3s">
<t-icon name="notification" size="16" color="#ff5f5c" class="guide-icon"></t-icon>
<text class="guide-text">保持二维码清晰可见</text>
</view>
</view>
</view>
<view class="current-status-area">
<view class="current-status-area animate__animated animate__fadeInUp" style="animation-delay: 0.2s">
<view class="status-header">
<text class="status-title">当前签到状态</text>
</view>
<view v-if="signInfo" class="status-content">
<template v-if="signInfo.isSignIn">
<view class="sign-success">
<t-icon name="task-checked-1" size="48" color="#07c160"></t-icon>
<div class="success-icon-wrapper">
<t-icon name="task-checked-1" size="48" color="#07c160"></t-icon>
<svg class="checkmark-circle" viewBox="0 0 52 52">
<circle class="checkmark-circle__circle" cx="26" cy="26" r="25" fill="none" />
</svg>
</div>
<text class="success-text">已完成签到</text>
<view class="sign-detail">
<view class="detail-item">
<uni-icons type="calendar" size="14" color="#666666" />
<uni-icons type="calendar" size="14" color="#666666"/>
<text>签到时间:{{ signInfo.signInTime }}</text>
</view>
<view class="detail-item">
<uni-icons type="location" size="14" color="#666666" />
<text>签到地点:{{ signInfo.location }}</text>
</view>
<!-- <view class="detail-item">-->
<!-- <uni-icons type="location" size="14" color="#666666"/>-->
<!-- <text>签到地点:{{ signInfo.location }}</text>-->
<!-- </view>-->
</view>
</view>
</template>
@@ -61,6 +68,29 @@
</view>
</view>
</view>
<div class="bg-decoration">
<svg class="wave" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320">
<path fill="#f45a5a" fill-opacity="0.1" d="M0,160L48,154.7C96,149,192,139,288,144C384,149,480,171,576,176C672,181,768,171,864,154.7C960,139,1056,117,1152,128C1248,139,1344,181,1392,202.7L1440,224L1440,0L1392,0C1344,0,1248,0,1152,0C1056,0,960,0,864,0C768,0,672,0,576,0C480,0,384,0,288,0C192,0,96,0,48,0L0,0Z"></path>
</svg>
<svg class="dots" width="100" height="100" viewBox="0 0 100 100">
<circle fill="#f45a5a" fill-opacity="0.2" cx="10" cy="10" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="30" cy="10" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="50" cy="10" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="70" cy="10" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="90" cy="10" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="10" cy="30" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="30" cy="30" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="50" cy="30" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="70" cy="30" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="90" cy="30" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="10" cy="50" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="30" cy="50" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="50" cy="50" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="70" cy="50" r="3" />
<circle fill="#f45a5a" fill-opacity="0.2" cx="90" cy="50" r="3" />
</svg>
</div>
</view>
<ScanQRCode
@@ -73,7 +103,7 @@
<script setup>
import {ref, computed, onMounted} from 'vue';
import {useRequest} from '@/utils/request';
import {Toast} from 'tdesign-mobile-vue';
import {Dialog, Toast} from 'tdesign-mobile-vue';
import moment from 'moment';
import ScanQRCode from '@/components/scanQrCode.vue'
@@ -99,15 +129,16 @@ onMounted(() => {
// 获取活动列表
async function listMineAct() {
try {
const {data, code} = await request.get('/client/activity/list-mine-act')
const {data, code, msg} = await request.get('/client/activity/list-mine-act')
if (code === 0) {
list.value = data
if (list.value.length) {
selectActId.value = list.value[0].id
console.log(selectActId.value)
await getSignInfo();
}
} else {
Toast.error(data.msg)
Toast.error(msg)
}
} catch (e) {
console.error(e)
@@ -116,9 +147,9 @@ async function listMineAct() {
}
// 获取签到信息
async function getSignInfo(val){
async function getSignInfo(val) {
try {
const {code, data, msg} = await request.get('/client/activity/sign-info/' + (val ? val :selectActId.value))
const {code, data, msg} = await request.get('/client/activity/sign-in-info/' + (val ? val : selectActId.value))
if (code === 0) {
signInfo.value = data
} else {
@@ -135,26 +166,125 @@ function handleScan() {
qrScannerRef.value.startScan()
}
function handleScanSuccess(val){
console.log(val)
Toast.success(val)
// 扫码成功回调
function handleScanSuccess(val) {
console.log('扫描成功,活动ID为' + val, "当前选择活动ID为" + selectActId.value)
if (!val) {
Toast.error("请扫描合法的二维码")
return
}
if (String(val) !== String(selectActId.value)) {
Toast.error('请扫码签到当前活动,扫描成功,活动ID为' + val + ",当前选择活动ID为" + selectActId.value)
return
}
signIn()
}
// 签到
async function signIn() {
try {
await uni.showLoading({
title: '签到中...',
mask: true,
})
const {code, msg} = await request.post('/client/activity/sign-in/' + selectActId.value)
uni.hideLoading()
Dialog.alert({
title: code === 0 ? '签到成功' : '签到失败',
content: code === 0 ? '祝您活动顺利' : msg,
confirmBtn: '确定'
})
if (code === 0) {
await getSignInfo()
}
} catch (e) {
Toast.error(e.message)
console.error(e)
} finally {
uni.hideLoading()
}
}
</script>
<style scoped lang="less">
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes ripple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
@keyframes checkmark-circle {
0% {
stroke-dashoffset: 166;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
100% {
transform: translateY(0);
}
}
.top-dropdown {
flex-shrink: 0;
z-index: 10;
position: relative;
}
.content-container {
padding: 15px;
z-index: 9;
position: relative;
flex: 1;
overflow-y: auto;
}
.scan-area {
background-color: #ffffff;
border-radius: 16rpx;
padding: 64rpx 0;
border-radius: 16px;
padding: 64px 0;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
}
.scan-icon-wrapper {
@@ -164,6 +294,62 @@ function handleScanSuccess(val){
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
position: relative;
}
.pulse-animation {
animation: pulse 2s infinite ease-in-out;
}
.scan-circles {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
&:before, &:after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
border: 2px solid rgba(244, 90, 90, 0.3);
animation: ripple 2s infinite;
}
&:after {
animation-delay: 0.5s;
}
}
.scan-button {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&:active {
transform: translateY(2px);
}
&:after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: rgba(255, 255, 255, 0.2);
transform: rotate(30deg);
transition: all 0.3s ease;
}
&:hover:after {
transform: rotate(30deg) translate(-30%, -30%);
}
}
.guide-area {
@@ -171,6 +357,9 @@ function handleScanSuccess(val){
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.guide-title {
@@ -178,18 +367,37 @@ function handleScanSuccess(val){
font-weight: 500;
color: #333333;
margin-bottom: 24rpx;
position: relative;
&:after {
content: '';
position: absolute;
bottom: -8rpx;
left: 0;
width: 40rpx;
height: 4rpx;
background-color: #f45a5a;
border-radius: 2rpx;
}
}
.guide-item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
padding: 12rpx 16rpx;
border-radius: 8rpx;
transition: all 0.3s ease;
&:hover {
background-color: rgba(244, 90, 90, 0.05);
}
}
.guide-icon {
width: 32rpx;
height: 32rpx;
margin-right: 16rpx;
margin-right: 16px;
flex-shrink: 0;
}
@@ -202,7 +410,9 @@ function handleScanSuccess(val){
background-color: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.status-header {
@@ -214,6 +424,18 @@ function handleScanSuccess(val){
font-weight: 500;
color: #333333;
margin-bottom: 0.75rem;
position: relative;
&:after {
content: '';
position: absolute;
bottom: -8rpx;
left: 0;
width: 40rpx;
height: 4rpx;
background-color: #f45a5a;
border-radius: 2rpx;
}
}
.status-content {
@@ -230,6 +452,34 @@ function handleScanSuccess(val){
text-align: center;
}
.success-icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
}
.checkmark-circle {
width: 96rpx;
height: 96rpx;
position: absolute;
top: 0;
left: 0;
&__circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke: #07c160;
stroke-miterlimit: 10;
fill: none;
animation: checkmark-circle 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
animation-delay: 0.2s;
}
}
.success-text {
font-size: 20px;
color: #07c160;
@@ -239,6 +489,9 @@ function handleScanSuccess(val){
.sign-detail {
margin-top: 32rpx;
background-color: rgba(7, 193, 96, 0.05);
padding: 16rpx 24rpx;
border-radius: 8rpx;
}
.detail-item {
@@ -247,6 +500,7 @@ function handleScanSuccess(val){
margin-bottom: 16rpx;
color: #666666;
font-size: 14px;
font-weight: 400;
}
.detail-item uni-icons {
@@ -265,4 +519,117 @@ function handleScanSuccess(val){
color: #999999;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
.wave {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 200px;
}
.dots {
position: absolute;
bottom: 5%;
right: 5%;
animation: float 4s ease-in-out infinite;
}
/* 导入Animate.css的部分动画 */
.animate__animated {
animation-duration: 1s;
animation-fill-mode: both;
}
.animate__fadeIn {
animation-name: fadeIn;
}
.animate__fadeInUp {
animation-name: fadeInUp;
}
.animate__fadeInLeft {
animation-name: fadeInLeft;
}
.animate__bounceIn {
animation-name: bounceIn;
}
.animate__pulse {
animation-name: pulse;
}
.animate__infinite {
animation-iteration-count: infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate3d(0, 40px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translate3d(-40px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes bounceIn {
from, 20%, 40%, 60%, 80%, to {
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
0% {
opacity: 0;
transform: scale3d(0.3, 0.3, 0.3);
}
20% {
transform: scale3d(1.1, 1.1, 1.1);
}
40% {
transform: scale3d(0.9, 0.9, 0.9);
}
60% {
opacity: 1;
transform: scale3d(1.03, 1.03, 1.03);
}
80% {
transform: scale3d(0.97, 0.97, 0.97);
}
to {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
</style>
@@ -0,0 +1,442 @@
<template>
<view class="page-container">
<t-dropdown-menu class="top-dropdown">
<t-dropdown-item :options="actOptions" :value="selectActId"/>
</t-dropdown-menu>
<view class="content-container">
<view class="page-header">
<text class="page-title">团队报名</text>
<text class="page-subtitle">选择您感兴趣的项目进行报名</text>
</view>
<!-- 项目列表 -->
<view class="project-cards">
<view
v-for="project in itemOptions"
:key="project.id"
class="project-card"
:class="{'project-card--registered': registrationStatus[project.id]?.length}"
@click="selectProject(project.id)"
>
<view class="project-card__content">
<view class="project-card__title">{{ project.name }}</view>
<view class="project-card__desc">{{ project.description }}</view>
</view>
<view class="project-card__status">
<t-tag v-if="registrationStatus[project.id]?.length" theme="success" variant="light-outline">
已报名 {{ registrationStatus[project.id]?.length }}人
</t-tag>
<view v-else class="project-card__action">
<t-icon name="chevron-right" color="#999"/>
</view>
</view>
<view v-if="registrationStatus[project.id]?.length" class="project-card__checkmark">
<t-icon name="check" color="#52c41a"/>
</view>
</view>
</view>
</view>
<t-popup v-model:visible="showMemberPicker" placement="bottom" destroy-on-close>
<view class="member-picker">
<view class="picker-header">
<text class="picker-title">选择项目参与成员</text>
<t-icon name="close" @click="showMemberPicker = false"/>
</view>
<view class="picker-subtitle" v-if="selectedItemId">
{{ itemOptions.find(p => p.id === selectedItemId)?.name }}
</view>
<view class="member-list">
<t-checkbox-group v-model="selectedMemberIds">
<view
v-for="member in teamMembers"
:key="member.id"
>
<t-checkbox
:value="member.id"
:label="member.userName"
:content="member.unitName"
:disabled="registrationStatus[selectedItemId]?.includes(member.id)"
icon-size="20px"
/>
<view class="member-status" v-if="registrationStatus[selectedItemId]?.includes(member.id)">
已报名
</view>
</view>
</t-checkbox-group>
</view>
<view class="picker-footer">
<t-button block theme="primary" @click="submitRegistration" :disabled="!selectedMemberIds.length">
确认报名 ({{ selectedMemberIds.length }}人)
</t-button>
</view>
</view>
</t-popup>
</view>
</template>
<script setup>
import {ref, onMounted, computed} from 'vue';
import {useRequest} from '@/utils/request';
import {Dialog, Toast} from 'tdesign-mobile-vue';
import {onLoad} from '@dcloudio/uni-app';
const request = useRequest();
// 活动数据
const actOptions = ref([])
// 选中的活动ID
const selectActId = ref(null);
// 项目列表数据
const itemOptions = ref([
{
id: 1,
name: '篮球比赛',
description: '3v3篮球友谊赛'
},
{
id: 2,
name: '足球比赛',
description: '5人制足球赛'
},
{
id: 3,
name: '羽毛球比赛',
description: '双打友谊赛'
}
]);
// 选中的项目ID
const selectedItemId = ref(null);
// 团队成员列表
const teamMembers = ref([]);
onLoad((option) => {
if (option.id) {
selectActId.value = option.id;
}
});
onMounted(async () => {
await listActs()
await listItems();
listTeamUser()
// fetchRegistrationStatus();
});
// 选中的团队成员
const selectedMemberIds = ref([]);
// 成员选择弹窗显示状态
const showMemberPicker = ref(false);
// 当前项目的报名状态
const registrationStatus = ref({});
// 获取活动列表
async function listActs() {
try {
const {data, code, msg} = await request.get('/client/activity/list-mine-act')
if (code === 0) {
actOptions.value = data && data.map(item => {
return {
value: item.id,
label: item.title
}
})
if(selectActId.value === null && data.length){
selectActId.value = data[0].id
}
} else {
Toast.error(msg)
}
} catch (e) {
console.error(e)
Toast.error(e.message)
}
}
// 获取项目列表
async function listItems() {
try {
const {code, data, msg} = await request.get('/client/item/list/' + selectActId.value)
if (code === 0) {
itemOptions.value = data;
}
} catch (e) {
console.error(e)
}
}
// 获取团队成员
async function listTeamUser(){
try {
const {code, data, msg} = await request.get('/client/item/list-team-user/'+selectActId.value)
if (code === 0) {
teamMembers.value = data;
}else{
Toast.error(msg)
}
}catch (e){
Toast.error(msg)
console.error(e)
}
}
// 获取报名状态 - 使用静态数据
const fetchRegistrationStatus = async () => {
};
// 选择项目
const selectProject = (projectId) => {
selectedItemId.value = projectId;
selectedMemberIds.value = [];
showMemberPicker.value = true;
};
// 提交报名 - 使用静态数据模拟
const submitRegistration = async () => {
if (!selectedMemberIds.value.length) {
Toast.warning('请选择团队成员');
return;
}
try {
console.log(selectedMemberIds.value)
console.log(selectedItemId.value)
console.log(selectActId.value)
const teamUser = teamMembers.value.filter(v=>selectedMemberIds.value.includes(v.id))
// const {code, msg} = await request.post('/client/item/sign-up', {
// activityId: selectActId.value,
// itemId: selectedItemId.value,
// userList: selectedMemberIds.value
// })
// if (code === 0) {
// Toast.success('报名成功')
// showMemberPicker.value = false;
// selectedMemberIds.value = []
// }else{
// Toast.error(msg)
// }
}catch (e){
Toast.error(e.message)
console.error(e)
}
};
</script>
<style scoped lang="less">
.top-dropdown {
flex-shrink: 0;
z-index: 10;
position: relative;
}
.content-container {
padding: 15px;
z-index: 9;
position: relative;
}
.page-header {
margin-bottom: 24px;
padding: 0 4px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
display: block;
}
.page-subtitle {
font-size: 14px;
color: #999;
display: block;
}
}
.project-cards {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.project-card {
background-color: #fff;
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
&--registered {
background: #fff;
.project-card__title,
.project-card__desc {
color: #333;
}
}
&__content {
flex: 1;
min-width: 0;
}
&__title {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__desc {
font-size: 14px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__status {
margin-left: 12px;
}
&__action {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
&__checkmark {
position: absolute;
top: 16px;
right: 16px;
color: #52c41a;
}
}
.member-picker {
background-color: #fff;
border-radius: 16px 16px 0 0;
padding: 20px;
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.picker-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.picker-subtitle {
font-size: 14px;
color: #ff5f5c;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.member-list {
max-height: 50vh;
overflow-y: auto;
margin-bottom: 20px;
padding: 0 4px;
}
.member-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
&--disabled {
opacity: 0.7;
}
.member-status {
font-size: 12px;
color: #52c41a;
}
:deep(.t-checkbox) {
margin: 0;
}
}
.picker-footer {
padding-top: 12px;
}
}
// 添加动画效果
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.project-card {
animation: fadeIn 0.3s ease-out;
animation-fill-mode: both;
}
.project-card:nth-child(1) {
animation-delay: 0.05s;
}
.project-card:nth-child(2) {
animation-delay: 0.1s;
}
.project-card:nth-child(3) {
animation-delay: 0.15s;
}
.project-card:nth-child(4) {
animation-delay: 0.2s;
}
.project-card:nth-child(5) {
animation-delay: 0.25s;
}
</style>
+317 -221
View File
@@ -1,112 +1,116 @@
<template>
<view class="page-container">
<t-dropdown-menu @menu-opened="handleMenuOpened" @menu-closed="handleMenuClosed">
<t-dropdown-item :options="actOptions" :value="pageForm.actId" @change="actChange" />
</t-dropdown-menu>
<view class="page-container" :style="{ backgroundColor: '#f7f7f9' }">
<t-sticky>
<t-dropdown-menu class="top-dropdown-menu">
<t-dropdown-item :options="actOptions" :value="pageForm.actId" @change="actChange" />
</t-dropdown-menu>
</t-sticky>
<!-- 团队卡片 -->
<div class="content-area">
<!-- 团队信息卡片 -->
<div class="team-card">
<div class="team-banner">
<div class="team-header">
<div class="team-logo">
<i class="bi bi-people-fill" v-if="!team.logoUrl"></i>
<img :src="team.logoUrl" alt="团队logo" v-if="team.logoUrl">
</div>
<div class="team-info">
<div class="team-basic-info">
<div class="team-name">{{ team.name }}</div>
<div class="team-meta">创建于 {{ team.createdDate }}</div>
<div class="team-id">团队ID: {{ team.id }}</div>
</div>
<div class="event-name">{{ team.eventName }}</div>
</div>
<div class="team-detail">
<div class="team-stat-row">
<div class="team-stat-item">
<div class="team-stat-value">{{ team.memberCount }}</div>
<div class="team-stat-label">团队成员</div>
</div>
<div class="team-stat-item">
<div class="team-stat-value">{{ team.rank }}</div>
<div class="team-stat-label">当前排名</div>
</div>
<div class="team-stat-item">
<div class="team-stat-value">{{ team.score }}</div>
<div class="team-stat-label">团队积分</div>
<div class="team-stats">
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-people"></i></div>
<div class="stat-content">
<div class="stat-value">{{ team.memberCount }}</div>
<div class="stat-label">成员</div>
</div>
</div>
<div style="font-size: 14px; color: #666; line-height: 1.6;">
{{ team.description }}
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-trophy"></i></div>
<div class="stat-content">
<div class="stat-value">{{ team.rank }}</div>
<div class="stat-label">排名</div>
</div>
</div>
</div>
<div v-if="team.description" class="team-desc-toggle" @click="showDesc = !showDesc">
<span>团队介绍</span>
<i class="bi" :class="showDesc ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
</div>
<div v-if="showDesc && team.description" class="team-desc">
<i class="bi bi-quote"></i>
{{ team.description }}
</div>
</div>
<!-- 团队成员 -->
<div class="team-section">
<!-- 项目报名按钮 -->
<div class="action-section">
<t-button block theme="danger" size="large" class="register-btn" @click="goToEventRegistration">
参与项目报名
</t-button>
</div>
<!-- 团队成员列表 -->
<div class="members-section">
<div class="section-header">
<div class="section-title">
<i class="bi bi-people"></i>团队成员
<i class="bi bi-people-fill"></i>
团队成员
</div>
<div class="section-badge">{{ team.memberCount }}</div>
</div>
<div class="member-list">
<div class="member-item" v-for="member in team.members" :key="member.id">
<div class="member-avatar">
<img :src="member.avatar" :alt="member.name" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LXNpemU9IjIwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmaWxsPSIjNTU1Ij57eyBtZW1iZXIubmFtZS5jaGFyQXQoMCkgfX08L3RleHQ+PC9zdmc+'">
<div class="member-grid">
<div class="member-grid-item" v-for="member in team.members" :key="member.id">
<div class="member-name">
{{ member.name }}
<t-tag theme="danger" size="small" v-if="member.role === 'leader'">队长</t-tag>
</div>
<div class="member-info">
<div class="member-name">
{{ member.name }}
<span class="member-role" v-if="member.role === 'leader'">队长</span>
</div>
<div class="member-detail">
<div class="member-detail-item">
<i class="bi bi-gender-ambiguous"></i>
<span>{{ member.gender }}</span>
</div>
<div class="member-detail-item">
<i class="bi bi-building"></i>
<span>{{ member.organization }}</span>
</div>
</div>
</div>
<div class="member-actions">
<i class="bi bi-three-dots-vertical"></i>
<div class="member-details">
<span><i class="bi bi-gender-ambiguous"></i> {{ member.gender }}</span>
<span><i class="bi bi-building"></i> {{ member.organization }}</span>
</div>
</div>
</div>
<!-- 当前角色与申请队长 -->
<div v-if="currentUserRole !== 'leader'">
<div class="role-notice">
<i class="bi bi-info-circle"></i>
您当前是普通成员身份
<div class="role-section">
<div class="role-card" v-if="currentUserRole === 'leader'">
<div class="role-icon">
<i class="bi bi-trophy"></i>
</div>
<div class="role-info">
<div class="role-title">您是队长</div>
<div class="role-desc">拥有团队所有管理权限</div>
</div>
</div>
<div v-else>
<div class="role-card">
<div class="role-icon">
<i class="bi bi-person"></i>
</div>
<div class="role-info">
<div class="role-title">您是普通成员</div>
<div class="role-desc">申请成为队长可获得更多权限</div>
</div>
</div>
<t-button block
class="leader-apply-btn"
theme="danger"
variant="outline"
@click="leaderConfirmVisible = true">
申请成为队长
</t-button>
</div>
<t-button block
theme="primary"
variant="outline"
size="large"
shape="round"
@click="leaderConfirmVisible = true">
<i class="bi bi-award"></i>申请成为队长
</t-button>
</div>
<div class="role-notice" v-else>
<i class="bi bi-check-circle"></i>
您当前是队长身份,拥有团队所有权限
</div>
</div>
<!-- 项目报名按钮 -->
<div class="team-section">
<t-button block
theme="primary"
size="large"
shape="round" @click="goToEventRegistration">
<i class="bi bi-calendar-check"></i>
项目报名
</t-button>
</div>
</div>
</view>
@@ -120,18 +124,24 @@
@cancel="leaderConfirmVisible = false"
>
<template #content>
<p>申请成为队长前,请确认您已与团队其他成员商议并达成一致意见。</p>
<p>一个团队仅能有一名队长,作为队长,您可以帮助队员一键报名项目。</p>
<div class="dialog-content">
<div class="dialog-icon">
<i class="bi bi-info-circle"></i>
</div>
<div class="dialog-text">
<p>申请成为队长前,请确认您已与团队其他成员商议并达成一致意见。</p>
<p>一个团队仅能有一名队长,作为队长,您可以帮助队员一键报名项目。</p>
</div>
</div>
</template>
</t-dialog>
</template>
<script setup>
import {ref,reactive} from "vue";
import {ref, reactive} from "vue";
const actOptions = ref([])
const showDesc = ref(false)
const pageForm = reactive({
actId: null,
@@ -216,45 +226,54 @@ function onLeaderConfirm(){
}
function goToEventRegistration(){}
</script>
<style scoped lang="less">
/* 主体内容区 */
:deep(.top-dropdown-menu) {
background-color: #fff;
padding: 10px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
z-index: 999;
}
.content-area {
padding: 0 16px 24px;
}
/* 团队信息卡片 */
/* 团队卡片 */
.team-card {
background-color: white;
margin: 15px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
background-color: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(255, 95, 92, 0.08);
margin: 12px 0 20px;
}
.team-banner {
position: relative;
height: 120px;
background: linear-gradient(135deg, #e74c3c, #ff7675);
.team-header {
display: flex;
align-items: center;
padding: 20px;
padding: 16px;
background: linear-gradient(to right, rgba(255, 95, 92, 0.05), rgba(255, 95, 92, 0.02));
border-bottom: 1px solid rgba(255, 95, 92, 0.1);
}
.team-logo {
width: 80px;
height: 80px;
border-radius: 50%;
width: 48px;
height: 48px;
border-radius: 12px;
background-color: white;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
border: 2px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(255, 95, 92, 0.15);
overflow: hidden;
flex-shrink: 0;
}
.team-logo i {
font-size: 24px;
color: #ff5f5c;
}
.team-logo img {
@@ -263,93 +282,137 @@ function goToEventRegistration(){}
object-fit: cover;
}
.team-logo i {
font-size: 40px;
color: #e74c3c;
}
.team-info {
.team-basic-info {
margin-left: 12px;
flex: 1;
color: white;
}
.team-name {
font-size: 20px;
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
color: #333;
margin-bottom: 4px;
}
.team-meta {
font-size: 13px;
opacity: 0.95;
}
.team-id {
display: inline-block;
background-color: rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: 10px;
margin-top: 6px;
font-size: 12px;
color: #999;
}
.event-name {
position: absolute;
bottom: 10px;
right: 15px;
background-color: rgba(0, 0, 0, 0.2);
padding: 3px 10px;
border-radius: 15px;
font-size: 12px;
color: white;
}
/* 团队详情内容 */
.team-detail {
padding: 15px;
}
.team-stat-row {
.team-stats {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 15px;
padding: 12px 16px;
}
.team-stat-item {
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 5px 0;
}
.team-stat-value {
font-size: 22px;
.stat-icon {
width: 36px;
height: 36px;
border-radius: 18px;
background-color: rgba(255, 95, 92, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
color: #ff5f5c;
font-size: 18px;
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-divider {
width: 1px;
height: 36px;
background-color: #f0f0f0;
margin: 0 16px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #e74c3c;
margin-bottom: 5px;
color: #333;
line-height: 1.2;
}
.team-stat-label {
font-size: 13px;
.stat-label {
font-size: 12px;
color: #999;
}
.team-desc-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 14px;
color: #666;
background-color: #f9f9f9;
border-top: 1px solid #f0f0f0;
cursor: pointer;
}
.team-desc-toggle i {
font-size: 12px;
color: #999;
}
/* 团队成员列表 */
.team-section {
.team-desc {
padding: 12px 16px;
font-size: 14px;
color: #666;
line-height: 1.5;
position: relative;
background-color: #fff8f8;
border-top: 1px solid #f0f0f0;
}
.team-desc i {
color: #ffdddd;
font-size: 18px;
position: absolute;
top: 8px;
left: 10px;
}
/* 操作按钮 */
.action-section {
margin-bottom: 20px;
}
.register-btn {
height: 48px;
font-size: 16px;
background-color: #ff5f5c;
border-color: #ff5f5c;
box-shadow: 0 6px 16px rgba(255, 95, 92, 0.2);
}
/* 成员列表 */
.members-section {
background-color: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 15px;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f5f5f5;
}
.section-title {
font-size: 17px;
font-size: 16px;
font-weight: 600;
color: #333;
display: flex;
@@ -358,105 +421,138 @@ function goToEventRegistration(){}
.section-title i {
margin-right: 8px;
color: #e74c3c;
font-size: 20px;
color: #ff5f5c;
font-size: 18px;
}
.member-list {
background: white;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.member-item {
.section-badge {
display: flex;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
background-color: #fff0f0;
color: #ff5f5c;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
padding: 0 8px;
}
.member-item:last-child {
border-bottom: none;
.member-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 12px;
}
.member-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #f0f0f0;
margin-right: 15px;
overflow: hidden;
flex-shrink: 0;
}
.member-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.member-info {
flex: 1;
.member-grid-item {
background-color: #f9f9f9;
border-radius: 8px;
padding: 10px;
}
.member-name {
font-size: 16px;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 5px;
margin-bottom: 2px;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-role {
display: inline-block;
.member-details {
display: flex;
flex-direction: column;
font-size: 12px;
background-color: #e74c3c;
color: white;
padding: 1px 6px;
border-radius: 4px;
margin-left: 8px;
color: #999;
}
.member-detail {
display: flex;
font-size: 13px;
color: #666;
}
.member-detail-item {
.member-details span {
display: flex;
align-items: center;
margin-right: 15px;
margin-top: 2px;
}
.member-detail-item i {
.member-details i {
margin-right: 4px;
font-size: 14px;
color: #999;
font-size: 12px;
color: #ccc;
}
.member-actions {
color: #999;
padding: 5px;
border-radius: 5px;
/* 角色相关 */
.role-section {
padding: 16px;
}
.role-notice {
background-color: #f8f9fa;
padding: 12px 15px;
border-radius: 8px;
margin-top: 15px;
font-size: 13px;
color: #666;
.role-card {
display: flex;
align-items: center;
background-color: #fff8f8;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(255, 95, 92, 0.05);
}
.role-notice i {
color: #ffa63f;
margin-right: 8px;
font-size: 16px;
.role-icon {
width: 40px;
height: 40px;
border-radius: 20px;
background: linear-gradient(120deg, #ff5f5c, #ff8c8a);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
box-shadow: 0 4px 12px rgba(255, 95, 92, 0.2);
}
.role-icon i {
font-size: 20px;
color: white;
}
.role-info {
flex: 1;
}
.role-title {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.role-desc {
font-size: 13px;
color: #666;
}
.leader-apply-btn {
margin-top: 8px;
border-radius: 12px;
height: 48px;
border-color: #ff5f5c;
color: #ff5f5c;
}
/* 弹窗内容样式 */
.dialog-content {
display: flex;
padding: 16px 0;
}
.dialog-icon {
margin-right: 16px;
font-size: 24px;
color: #ff9800;
}
.dialog-text p {
margin: 0 0 8px;
font-size: 14px;
color: #666;
line-height: 1.6;
}
</style>
+1 -3
View File
@@ -1,11 +1,9 @@
<template>
<view class="page-container">
<!-- 顶部背景和内容区 -->
<view class="header">
<image class="header-bg" src="https://erp-bucket.pek3b.qingstor.com/assistant/89c4b573-c54f-467a-b703-0bc44679ab08.png" mode="aspectFill"></image>
</view>
<!-- 功能菜单区 -->
<view class="menu-container">
<view class="menu-item" @click="navigateTo('/pages/activity/list/index')">
<view class="menu-icon-wrapper red-bg">
@@ -26,7 +24,7 @@
</view>
<view class="menu-text">我的团队</view>
</view>
<view class="menu-item" @click="navigateTo('/pages/activity/team/index')">
<view class="menu-item" @click="navigateTo('/pages/activity/team-form/index')">
<view class="menu-icon-wrapper blue-bg">
<view class="menu-icon star-icon"></view>
</view>
+330 -151
View File
@@ -1,51 +1,118 @@
<template>
<view class="login-container">
<image
class="banner-image"
src="https://res.firstui.cn/template/loginB/login/img_banner_3x.png"
mode="aspectFill"
/>
<view class="title-area">
<div class="tech-animation">
<svg preserveAspectRatio="xMidYMid slice" viewBox="0 0 300 100">
<pattern id="pattern-hex" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="scale(0.8)">
<rect x="0" y="0" width="10" height="10" fill="none"/>
<path d="M0,5 L2.5,0 L7.5,0 L10,5 L7.5,10 L2.5,10 Z" stroke-width="0.5" stroke="rgba(244, 90, 90, 0.2)" fill="rgba(244, 90, 90, 0.1)">
<animate attributeName="stroke-opacity" values="0.1;0.3;0.1" dur="3s" repeatCount="indefinite" />
</path>
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern-hex)" />
<view class="login-form">
<text class="title">登录</text>
<g fill="none" stroke-width="0.5">
<path d="M20,50 Q60,20 100,50 T180,50 T260,50" stroke="rgba(244, 90, 90, 0.6)">
<animate attributeName="d"
values="M20,50 Q60,20 100,50 T180,50 T260,50;
M20,50 Q60,80 100,50 T180,50 T260,50;
M20,50 Q60,20 100,50 T180,50 T260,50"
dur="10s" repeatCount="indefinite" />
</path>
<t-input
class="input-item"
v-model="loginForm.phone"
placeholder="请输入手机号码"
>
</t-input>
<path d="M20,70 Q60,40 100,70 T180,70 T260,70" stroke="rgba(244, 90, 90, 0.4)">
<animate attributeName="d"
values="M20,70 Q60,40 100,70 T180,70 T260,70;
M20,70 Q60,100 100,70 T180,70 T260,70;
M20,70 Q60,40 100,70 T180,70 T260,70"
dur="12s" repeatCount="indefinite" />
</path>
</g>
<t-input
class="input-item"
v-model="loginForm.smsCode"
placeholder="请输入验证码"
>
<template #suffix>
<t-button variant="text" class="verify-btn" @click="getSmsCode" :disabled="isCountingDown">
<circle cx="50" cy="50" r="1" fill="rgba(244, 90, 90, 0.8)">
<animate attributeName="cx" values="50;250;50" dur="20s" repeatCount="indefinite" />
<animate attributeName="cy" values="50;30;80;50" dur="16s" repeatCount="indefinite" />
<animate attributeName="r" values="1;1.5;1" dur="5s" repeatCount="indefinite" />
</circle>
<circle cx="150" cy="30" r="1" fill="rgba(244, 90, 90, 0.8)">
<animate attributeName="cx" values="150;100;200;150" dur="25s" repeatCount="indefinite" />
<animate attributeName="cy" values="30;60;20;30" dur="18s" repeatCount="indefinite" />
<animate attributeName="r" values="1;1.8;1" dur="6s" repeatCount="indefinite" />
</circle>
<circle cx="250" cy="70" r="1" fill="rgba(244, 90, 90, 0.8)">
<animate attributeName="cx" values="250;150;250" dur="22s" repeatCount="indefinite" />
<animate attributeName="cy" values="70;40;70" dur="15s" repeatCount="indefinite" />
<animate attributeName="r" values="1;1.2;1" dur="4s" repeatCount="indefinite" />
</circle>
</svg>
</div>
<view class="title-content">
<text class="main-title">HELLO,</text>
<view class="sub-title-container">
<text class="sub-title">欢迎使用</text>
</view>
</view>
</view>
<view class="login-card">
<view class="login-tabs">
<view
:class="['tab', activeMethod === 'phone' ? 'active' : '']"
@click="setActiveMethod('phone')"
>
手机快速登录
</view>
<view
:class="['tab', activeMethod === 'password' ? 'active' : '']"
@click="setActiveMethod('password')"
>
账号密码登录
</view>
</view>
<!-- 登录表单区域 -->
<view class="form-container">
<!-- 手机号码输入框 -->
<view class="input-box">
<t-icon name="mobile" size="20px" class="input-icon" />
<input
class="input"
type="tel"
v-model="loginForm.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<!-- 验证码输入框 -->
<view class="input-box">
<t-icon name="shield-error" size="20px" class="input-icon" />
<input
class="input"
type="number"
v-model="loginForm.smsCode"
placeholder="请输入验证码"
maxlength="6"
/>
<view class="verify-code-btn" @click="getSmsCode" :class="{ disabled: isCountingDown }">
{{ countDownText }}
</t-button>
</template>
</t-input>
</view>
</view>
<!-- <view class="login-options">-->
<!-- <text>账号登录</text>-->
<!-- <text>注册</text>-->
<!-- </view>-->
<t-button block theme="primary" size="large" class="login-btn" @click="handleLogin">
登录
</t-button>
<!-- <view class="other-login">-->
<!-- <text class="other-text">其他登录方式</text>-->
<!-- <view class="login-methods">-->
<!-- <t-button shape="circle" variant="outline" icon="qq" class="method-btn" />-->
<!-- <t-button shape="circle" variant="outline" icon="wechat" class="method-btn" />-->
<!-- <t-button shape="circle" variant="outline" icon="image" class="method-btn" />-->
<!-- </view>-->
<!-- </view>-->
<!-- 登录按钮 -->
<view class="submit-btn" @click="handleLogin">
立即登录
</view>
<!-- &lt;!&ndash; 注册链接 &ndash;&gt;-->
<!-- <view class="register-link">-->
<!-- <text>没有账号?</text>-->
<!-- <text class="link">去注册</text>-->
<!-- </view>-->
</view>
</view>
</view>
</template>
@@ -56,12 +123,17 @@ import {Toast} from 'tdesign-mobile-vue';
import {useRequest} from "@/utils/request";
const request = useRequest()
const activeMethod = ref('phone')
const loginForm = reactive({
phone: '18571562330',
phone: '',
smsCode: ''
})
const setActiveMethod = (method) => {
activeMethod.value = method
}
// 倒计时相关
const countdown = ref(0)
const timer = ref(null)
@@ -84,7 +156,7 @@ const saveCountdownState = (seconds, phone) => {
uni.removeStorageSync(SMS_PHONE_KEY)
return
}
// 保存结束时间戳和手机号
const endTime = Date.now() + seconds * 1000
uni.setStorageSync(SMS_COUNTDOWN_KEY, endTime)
@@ -96,13 +168,18 @@ const restoreCountdownState = () => {
const endTime = uni.getStorageSync(SMS_COUNTDOWN_KEY)
const savedPhone = uni.getStorageSync(SMS_PHONE_KEY)
if (endTime && savedPhone && savedPhone === loginForm.phone) {
if (endTime && savedPhone) {
const now = Date.now()
const remainingSeconds = Math.floor((endTime - now) / 1000)
if (remainingSeconds > 0) {
countdown.value = remainingSeconds
startCountDownTimer() // 重新启动定时器
// 如果用户还没输入手机号,填入保存的手机号
if (!loginForm.phone) {
loginForm.phone = savedPhone
}
} else {
// 倒计时已结束,清除存储
uni.removeStorageSync(SMS_COUNTDOWN_KEY)
@@ -116,13 +193,13 @@ const startCountDownTimer = () => {
if (timer.value) {
clearInterval(timer.value)
}
timer.value = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer.value)
timer.value = null
// 清除存储
uni.removeStorageSync(SMS_COUNTDOWN_KEY)
uni.removeStorageSync(SMS_PHONE_KEY)
@@ -137,50 +214,69 @@ const startCountDown = () => {
startCountDownTimer()
}
// 在页面挂载时恢复倒计时状态
onMounted(() => {
restoreCountdownState()
})
// 在页面卸载前清除定时器
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
// 获取验证码
async function getSmsCode() {
if (!loginForm.phone) {
Toast.error('请输入手机号码');
Toast('请输入手机号码');
return
}
// 验证手机号码
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
Toast('请输入正确的手机号码');
return
}
if (isCountingDown.value) return
try {
const {code, data, msg} = await request.post('/client/auth/send-sms-code/' + loginForm.phone)
if (code === 0) {
Toast.success('验证码发送成功')
Toast({message: '验证码发送成功', theme: 'success'})
// 开始倒计时
startCountDown()
} else {
Toast.error(msg)
Toast({message: msg, theme: 'error'})
}
} catch (e) {
Toast.error(e.message)
Toast({message: e.message, theme: 'error'})
}
}
async function handleLogin() {
if (!loginForm.phone) {
Toast.error('请输入手机号码');
Toast('请输入手机号码');
return
}
if (!loginForm.smsCode) {
Toast.error('请输入验证码');
Toast('请输入验证码');
return
}
try {
const {data, code, msg} = await request.post('/client/auth/sms-login', loginForm)
if (code === 0) {
Toast.success('登录成功')
Toast({message: '登录成功', theme: 'success'})
// 保存token
uni.setStorageSync('token', data)
// 获取之前保存的重定向URL
const redirectUrl = uni.getStorageSync('REDIRECT_URL') || '/pages/index/index'
uni.removeStorageSync('REDIRECT_URL') // 使用后删除
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
if (redirectUrl.startsWith('/pages/index/') || redirectUrl.startsWith('/pages/profile/')) {
@@ -196,119 +292,202 @@ async function handleLogin() {
}
}, 1000)
} else {
Toast.error(msg)
Toast({message: msg, theme: 'error'})
}
} catch (e) {
console.error(e)
Toast.error(e.message)
Toast({message: e.message, theme: 'error'})
}
}
// 页面加载时恢复倒计时状态
onMounted(() => {
restoreCountdownState()
})
// 页面卸载前清理定时器
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
</script>
<style lang="less" scoped>
.login-container {
min-height: 100vh;
background-color: #fff;
position: relative;
}
.banner-image {
body {
height: 100%;
overflow: hidden;
position: fixed;
width: 100%;
height: 260px;
background-color: #4080ff;
top: 0;
left: 0;
}
.login-form {
padding: 20px;
.login-container {
height: 100vh;
background: #f4f2f2;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.title {
font-size: 24px;
font-weight: bold;
margin: 20px 0;
display: block;
color: var(--td-brand-color-5);
.title-area {
padding: 0;
height: 30vh;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
background: linear-gradient(135deg, #7a1818 0%, #d03e3e 50%, #f45a5a 100%);
}
.tech-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.tech-animation svg {
width: 100%;
height: 100%;
}
.title-content {
padding: 0 30px;
position: relative;
z-index: 1;
}
.main-title {
font-size: 36px;
font-weight: bold;
margin-bottom: 8px;
display: block;
position: relative;
z-index: 1;
color: #ffffff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.sub-title-container {
display: flex;
align-items: center;
position: relative;
z-index: 1;
}
.sub-title {
font-size: 18px;
color: #ffffff;
position: relative;
z-index: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.login-card {
flex: 1;
background-color: #ffffff;
border-radius: 30px 30px 0 0;
margin-top: -30px;
padding: 30px 0 0;
position: relative;
z-index: 2;
box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.05);
}
.login-tabs {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
position: relative;
gap: 10px;
padding: 0 30px;
}
.tab {
width: calc(50% - 5px);
height: 55px;
display: flex;
justify-content: center;
align-items: center;
font-size: 15px;
color: #f45a5a;
font-weight: 500;
background-color: #fff0f0;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.tab:first-child {
border-radius: 25px 15px 30px 25px;
clip-path: polygon(0 0, 100% 0, 90% 100%, 0 100%);
}
.tab:last-child {
border-radius: 30px 25px 25px 15px;
clip-path: polygon(10% 0, 100% 0, 100% 100%, 0 100%);
}
.tab.active {
color: #fff;
background-color: #f45a5a;
}
.form-container {
padding: 0 30px;
}
.input-box {
height: 50px;
background-color: #eceaea;
border-radius: 25px;
display: flex;
align-items: center;
padding: 0 15px;
margin-bottom: 16px;
}
.input-icon {
color: #999;
margin-right: 10px;
}
.input {
flex: 1;
height: 100%;
border: none;
font-size: 15px;
background-color: transparent;
}
.input::placeholder {
color: #bcbcbc;
}
.verify-code-btn {
color: #f45a5a;
font-size: 14px;
white-space: nowrap;
&.disabled {
color: #999;
}
}
.input-item {
margin-bottom: 20px;
}
.submit-btn {
margin-top: 32px;
height: 50px;
background-color: #f45a5a;
border-radius: 25px;
color: white;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.country-code {
color: #333;
font-weight: bold;
}
.register-link {
margin-top: 16px;
text-align: center;
font-size: 14px;
color: #666;
.verify-btn {
color: var(--td-brand-color-5);
}
.login-options {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
font-size: 14px;
color: #666;
}
.login-btn {
margin: 20px 0;
}
.other-login {
margin-top: 40px;
text-align: center;
.other-text {
color: #999;
font-size: 14px;
position: relative;
display: inline-block;
padding: 0 10px;
background: #fff;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 60px;
height: 1px;
background: #eee;
}
&::before {
left: -70px;
}
&::after {
right: -70px;
}
}
.login-methods {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 20px;
.method-btn {
width: 40px;
height: 40px;
}
}
.link {
color: #f45a5a;
margin-left: 4px;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

+9
View File
@@ -0,0 +1,9 @@
// KB转为MB的函数 取整
export function convertKBToMB(kb) {
return Math.floor(kb / 1024) + 'MB'
}
// 字节转为MB的函数
export function convertBytesToMB(bytes) {
return (bytes / 1024 / 1024).toFixed(2) + 'MB'
}
+60 -177
View File
@@ -15,31 +15,11 @@ export const requestState = reactive({
// 当前正在加载的请求数量
get loadingCount() {
return Object.keys(this.loadingRequests).length;
},
// 所有错误请求ID映射表
errorRequests: {},
// 是否有任何请求出错
get hasError() {
return Object.keys(this.errorRequests).length > 0;
},
// 当前错误的请求数量
get errorCount() {
return Object.keys(this.errorRequests).length;
},
// 重置所有状态
reset() {
this.loadingRequests = {};
this.errorRequests = {};
}
});
/**
* 生成唯一的请求ID
* @returns {string} 唯一ID
*/
function generateRequestId() {
return Date.now() + "-" + Math.random().toString(36).substr(2, 9);
@@ -47,73 +27,52 @@ function generateRequestId() {
/**
* 请求开始处理
* @param {string} requestId - 请求ID
*/
function handleRequestStart(requestId) {
requestState.loadingRequests[requestId] = true;
// 从错误列表中删除,因为请求重新开始了
delete requestState.errorRequests[requestId];
}
/**
* 请求结束处理
* @param {string} requestId - 请求ID
*/
function handleRequestEnd(requestId) {
delete requestState.loadingRequests[requestId];
}
/**
* 请求错误处理
* @param {string} requestId - 请求ID
* @param {object} error - 错误对象
* 处理未登录状态
*/
function handleRequestError(requestId, error) {
requestState.errorRequests[requestId] = error;
handleRequestEnd(requestId);
function handleUnauthorized() {
// Token失效,清除存储并跳转登录
uni.removeStorageSync("token");
uni.removeStorageSync("userInfo");
uni.showToast({
title: "登录已过期,请重新登录",
icon: "none",
duration: 2000
});
setTimeout(() => {
uni.navigateTo({ url: "/pages/login/index" });
}, 2000);
}
/**
* 显示错误提示
* @param {object} error - 错误对象
* @param {boolean} showErrorToast - 是否显示错误提示
*/
function showError(error, showErrorToast) {
if (showErrorToast) {
// 获取错误消息
const message = error.errMsg || error.message || "请求失败";
uni.showToast({
title: message,
icon: "none",
duration: 2000
});
}
console.error("Request Error:", error);
}
/**
* 使用增强的请求功能
* @param {object} options - 请求配置
* @returns {object} 包含请求方法的对象
* 使用简化的请求功能
*/
export function useRequest(options = {}) {
const defaultOptions = {
baseUrl: "/api",
showErrorToast: true,
timeout: 60000,
retryCount: 0,
retryDelay: 1000,
...options
};
/**
* 执行HTTP请求
* @param {object} config - 请求配置
* @returns {Promise} 请求Promise
*/
const request = (config) => {
const requestId = generateRequestId();
let retries = 0;
// 合并配置
const mergedConfig = {
@@ -132,121 +91,57 @@ export function useRequest(options = {}) {
mergedConfig.url = `${mergedConfig.baseUrl}${mergedConfig.url}`;
}
/**
* 执行实际请求
* @returns {Promise} 请求Promise
*/
const executeRequest = () => {
handleRequestStart(requestId);
return new Promise((resolve, reject) => {
uni.request({
...mergedConfig,
success: (response) => {
const { statusCode, data } = response;
// 成功状态码处理
if (statusCode >= 200 && statusCode < 300) {
// API业务逻辑成功
if (data.code === 0 || data.code === 200 || data.status === 200) {
resolve(data);
}
// API业务逻辑失败
else {
const error = {
code: data.code || statusCode,
message: data.message || data.msg || "请求失败",
data: data
};
// 特殊错误码处理
if (data.code === 600098) {
// Token失效,清除存储并跳转登录
uni.removeStorageSync("token");
uni.removeStorageSync("userInfo");
uni.showToast({
title: "登录已过期,请重新登录",
icon: "none",
duration: 2000
});
setTimeout(() => {
uni.navigateTo({ url: "/pages/login/index" });
}, 2000);
}
showError(error, mergedConfig.showErrorToast);
handleRequestError(requestId, error);
reject(error);
}
}
// HTTP错误状态码
else {
const error = {
code: statusCode,
message: `HTTP错误: ${statusCode}`,
data: data
};
showError(error, mergedConfig.showErrorToast);
// 是否需要重试
if (retries < mergedConfig.retryCount) {
retries++;
setTimeout(() => {
handleRequestEnd(requestId);
retryRequest();
}, mergedConfig.retryDelay);
} else {
handleRequestError(requestId, error);
reject(error);
}
}
},
fail: (error) => {
showError(error, mergedConfig.showErrorToast);
// 是否需要重试
if (retries < mergedConfig.retryCount) {
retries++;
setTimeout(() => {
handleRequestEnd(requestId);
retryRequest();
}, mergedConfig.retryDelay);
} else {
handleRequestError(requestId, error);
reject(error);
}
},
complete: () => {
if (retries === 0 || retries >= mergedConfig.retryCount) {
handleRequestEnd(requestId);
handleRequestStart(requestId);
return new Promise((resolve, reject) => {
uni.request({
...mergedConfig,
success: (response) => {
const { statusCode, data } = response;
// HTTP状态码成功
if (statusCode >= 200 && statusCode < 300) {
if (data.code === 600098) {
handleUnauthorized();
reject(data);
}else{
resolve(data);
}
// // 业务逻辑成功
// if (data.code === 0) {
// resolve(data);
// }
// // 未登录
// else if (data.code === 600098) {
// handleUnauthorized();
// reject(data);
// }
// // 其他业务错误,直接返回,由业务代码处理
// else {
// reject(data);
// }
}
// HTTP错误
else {
reject({
code: statusCode,
message: `HTTP错误: ${statusCode}`,
data: data
});
}
});
},
fail: (error) => {
reject(error);
},
complete: () => {
handleRequestEnd(requestId);
}
});
};
/**
* 重试请求
* @returns {Promise} 重试请求Promise
*/
const retryRequest = () => {
console.log(`重试请求 (${retries}/${mergedConfig.retryCount})...`);
return executeRequest();
};
return executeRequest();
});
};
/**
* GET请求
* @param {string} url - 请求URL
* @param {object} params - 查询参数
* @param {object} config - 其他配置
* @returns {Promise} 请求Promise
*/
const get = (url, params = {}, config = {}) => {
return request({
@@ -259,10 +154,6 @@ export function useRequest(options = {}) {
/**
* POST请求
* @param {string} url - 请求URL
* @param {object} data - 请求数据
* @param {object} config - 其他配置
* @returns {Promise} 请求Promise
*/
const post = (url, data = {}, config = {}) => {
return request({
@@ -275,10 +166,6 @@ export function useRequest(options = {}) {
/**
* PUT请求
* @param {string} url - 请求URL
* @param {object} data - 请求数据
* @param {object} config - 其他配置
* @returns {Promise} 请求Promise
*/
const put = (url, data = {}, config = {}) => {
return request({
@@ -291,10 +178,6 @@ export function useRequest(options = {}) {
/**
* DELETE请求
* @param {string} url - 请求URL
* @param {object} data - 请求数据
* @param {object} config - 其他配置
* @returns {Promise} 请求Promise
*/
const del = (url, data = {}, config = {}) => {
return request({
+4 -4
View File
@@ -28,10 +28,10 @@ export default defineConfig({
}
},
server: {
https: {
cert: fs.readFileSync(path.join(__dirname, 'src/ssl/localhost+2.pem')),
key: fs.readFileSync(path.join(__dirname, 'src/ssl/localhost+2-key.pem'))
},
// https: {
// cert: fs.readFileSync(path.join(__dirname, 'src/ssl/localhost+2.pem')),
// key: fs.readFileSync(path.join(__dirname, 'src/ssl/localhost+2-key.pem'))
// },
proxy: {
'/api': {
target: 'http://192.168.41.118:9090',