Compare commits

...

4 Commits

Author SHA1 Message Date
sugarBecomer 988cc7be6f ✨ 请假功能 2026-03-25 13:57:15 +08:00
sugarBecomer b890929d0b ✨ 请假功能 2026-03-25 13:47:11 +08:00
sugarBecomer adbf885d95 🐛 调整两个版本邮件格式 2026-03-24 22:27:32 +08:00
sugarBecomer 99c58a3d5f 🐛 调整两个版本邮件格式 2026-03-24 22:13:02 +08:00
24 changed files with 1018 additions and 123 deletions
+3 -1
View File
@@ -4,4 +4,6 @@
node_modules
.claude
*.exe
.serena
.serena
device_code_cfworker
+12
View File
@@ -1,6 +1,18 @@
[ocr]
url = "https://devocr.likedream.xyz"
# ===== 应用品牌配置 =====
# brand: "default"(老系统/若梦系统/MD3风格邮件) 或 "new"(新系统/天神系统/shadcn风格邮件)
# 未设置时默认使用 "default"
[app]
brand = "default"
name = "若梦系统"
copyright = "© 2025 若梦系统. 保留所有权利."
# 邮件中的链接地址(会覆盖 server 段中的同名配置)
# emailConfirmBaseUrl = "https://ujob.us.ci/confirm/"
# emailResetPasswordUrl = "https://ujob.us.ci/reset-password/"
# balanceMigrateUrl = "https://ujob.us.ci/balance-migrate/"
[quesbank]
url = "http://tk.swk.tw/api/search.php"
key = "sk-acacac439b346036df45e90c85c177c375568ef72c469e49"
+49
View File
@@ -35,3 +35,52 @@ type CpLeaveUploadReq struct {
File *ghttp.UploadFile `json:"file"`
CpUserId int64 `json:"cpUserId"`
}
// 请假 - 检查是否允许请假
type CpLeaveCheckReq struct {
g.Meta `path:"/leave/check/{cpUserId}" method:"get" tags:"CpLeave" summary:"检查是否允许请假"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
// 请假 - 获取请假类型
type CpLeaveTypesReq struct {
g.Meta `path:"/leave/types/{cpUserId}" method:"get" tags:"CpLeave" summary:"获取请假类型列表"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
// 请假 - 查询请假类型配置(是否需要审批等)
type CpLeaveTypeCfgReq struct {
g.Meta `path:"/leave/typeCfg" method:"post" tags:"CpLeave" summary:"查询请假类型配置"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
Code int `json:"code" v:"required#请假类型code不能为空"`
}
// 请假 - 获取审批人列表
type CpLeaveApproveFlowReq struct {
g.Meta `path:"/leave/approveFlow" method:"post" tags:"CpLeave" summary:"获取审批人列表"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType int `json:"leaveType" v:"required#请假类型不能为空"`
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
}
// 请假 - 提交请假申请
type CpLeaveApplyReq struct {
g.Meta `path:"/leave/apply" method:"post" tags:"CpLeave" summary:"提交请假申请"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType int `json:"leaveType" v:"required#请假类型不能为空"`
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
UrgencyMobile string `json:"urgencyMobile" v:"required#紧急联系人手机号不能为空"`
LeaveReason string `json:"leaveReason" v:"required#请假原因不能为空"`
OutStatus bool `json:"outStatus"`
Address string `json:"address" v:"required#目的地不能为空"`
DetailAddress string `json:"detailAddress" v:"required#详细地址不能为空"`
Longitude string `json:"longitude" v:"required#经度不能为空"`
Latitude string `json:"latitude" v:"required#纬度不能为空"`
LocationAddr string `json:"locationAddr" v:"required#定位地址不能为空"`
ImageUrl string `json:"imageUrl"`
ApproverWid string `json:"approverWid"`
ApproverId string `json:"approverId"`
ApproverName string `json:"approverName"`
}
+11
View File
@@ -91,3 +91,14 @@ type CpUserCustomDeviceReq struct {
MobileCode string `json:"mobileCode" v:"required#请输入设备码"`
MobileType string `json:"mobileType" v:"required|in:random,manual#请指定设备码类型|设备码类型无效"`
}
// ---- 二维码获取设备码流程 ----
type CpUserGenerateQrReq struct {
g.Meta `path:"/user/generateQr" method:"post" tags:"CpUser" summary:"生成设备码二维码"`
}
type CpUserCheckQrReq struct {
g.Meta `path:"/user/checkQr" method:"get" tags:"CpUser" summary:"校验二维码UUID是否有效" auth:"false"`
Uuid string `json:"uuid" v:"required#请提供二维码UUID"`
}
+146
View File
@@ -0,0 +1,146 @@
package leave
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/leave/model"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/container/gvar"
"github.com/tidwall/gjson"
)
// CheckOnceLeave 检查是否允许请假
func (c *CpdailyReportApp) CheckOnceLeave() error {
post, err := c.client.R().SetBody(map[string]any{}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/checkOnceLeave", c.host))
if err != nil {
return err
}
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
return errors.NewBusinessError(400, result.Get("message").String())
}
return nil
}
// QueryLeaveTypes 获取请假类型列表
func (c *CpdailyReportApp) QueryLeaveTypes() gjson.Result {
post, err := c.client.R().SetBody(map[string]any{}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/queryLeaveTypes", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas.leaveType")
}
// QueryLeaveTypeCfg 查询请假类型配置
func (c *CpdailyReportApp) QueryLeaveTypeCfg(code int) gjson.Result {
post, err := c.client.R().SetBody(map[string]any{"code": code}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas")
}
// ObtainApproveFlow 获取审批人列表
func (c *CpdailyReportApp) ObtainApproveFlow(leaveType int, startTime, endTime string) gjson.Result {
post, err := c.client.R().SetBody(map[string]any{
"leaveType": leaveType,
"startTime": startTime,
"endTime": endTime,
}).Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/obtainApproveFlow", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas")
}
// SubmitLeaveApply 提交请假申请
func (c *CpdailyReportApp) SubmitLeaveApply(ctx context.Context, req *v1.CpLeaveApplyReq, username, deviceId string) {
cpdailyInfo := cpdaily.GetInfo(ctx)
// 构造请假申请体
applyBody := map[string]any{
"leaveType": req.LeaveType,
"startTime": req.StartTime,
"endTime": req.EndTime,
"urgencyMobile": req.UrgencyMobile,
"leaveReason": req.LeaveReason,
"isAttendance": nil,
"outStatus": req.OutStatus,
"address": req.Address,
"detailAddress": req.DetailAddress,
"destination": req.Address + "/" + req.DetailAddress,
"holidayAutoDefineVos": []any{},
"appVersion": cpdailyInfo.AppVersion,
"deviceId": deviceId,
"ccInfo": map[string]any{"teachers": []any{}, "students": []any{}},
"cheatType": nil,
"source": 1,
"location": map[string]any{
"deviceId": deviceId,
"systemName": "Android",
"systemVersion": cpdailyInfo.SystemVersion,
"model": "Redmi Note 8 Pro",
"address": req.LocationAddr,
"latitude": req.Latitude,
"longitude": req.Longitude,
},
"locationType": 0,
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
"uaIsCpadaily": "true",
"leaveApplyExplain": nil,
}
// 审批人信息
if req.ApproverWid != "" {
applyBody["approverWid"] = req.ApproverWid
applyBody["approverId"] = req.ApproverId
applyBody["approverName"] = req.ApproverName
}
// 图片附件
if req.ImageUrl != "" {
applyBody["applyAttach"] = []model.Attach{{Type: 1, Url: req.ImageUrl}}
} else {
applyBody["applyAttach"] = []any{}
}
// 构造加密扩展头
extensionForm := map[string]*gvar.Var{
"lon": gvar.New(req.Longitude),
"lat": gvar.New(req.Latitude),
"model": gvar.New("Redmi Note 8 Pro"),
"appVersion": gvar.New(cpdailyInfo.AppVersion),
"systemVersion": gvar.New(cpdailyInfo.SystemVersion),
"userId": gvar.New(username),
"systemName": gvar.New("android"),
"deviceId": gvar.New(deviceId),
}
extensionString := taskhelper.GetCpdailyExtension(ctx, extensionForm)
// 提交请假
response, e := c.client.R().
SetBody(applyBody).
WithContext(ctx).
SetHeader("Cpdaily-Extension", extensionString).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/apply", c.host))
utils.OkOrPanic(e)
responseJSON := utils.ReadGjsonResult(response.Body)
if responseJSON.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()))
}
}
+2
View File
@@ -7,6 +7,7 @@ const (
RedisBalanceMigrate = "balancemigrate:" // 积分转移token
RedisCpUserLogin = "cpuserlogin:"
RedisCpUserAuthorize = "cpuser:authorize:"
RedisCpUserQrCode = "cpuser:qrcode:" // 二维码UUID -> 系统用户ID
RedisSchoolInfo = "cpuser:schoolinfo:"
)
@@ -17,5 +18,6 @@ var (
RedisBalanceMigrateExpire = int64(3600) // 积分转移链接1小时有效
RedisCpUserLoginExpire = int64(180)
RedisCpUserAuthorizeExpire = int64(1800)
RedisCpUserQrCodeExpire = int64(300) // 二维码5分钟有效
RedisSchoolInfoExpire = int64(3600 * 24 * 30) // 学校信息30天有效
)
@@ -31,6 +31,31 @@ func (c CpLeaveCtrlV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (re
return service.Global.CpLeave.Upload(ctx, req), nil
}
func (c CpLeaveCtrlV1) CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.CheckLeave(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetLeaveTypes(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetLeaveTypeCfg(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetApproveFlow(ctx, req), nil
}
func (c CpLeaveCtrlV1) Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.Apply(ctx, req), nil
}
func NewCpLeaveCtrlV1() ICpLeaveCtrlV1 {
return &CpLeaveCtrlV1{}
}
@@ -101,3 +101,13 @@ func (c CpUserCtrlV1) AddByCode(ctx context.Context, req *v1.AddByCode) (res *ut
}
return service.Global.CpUser.AddByCode(ctx, req), nil
}
func (c CpUserCtrlV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpUser.GenerateQr(ctx, req), nil
}
func (c CpUserCtrlV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpUser.CheckQr(ctx, req), nil
}
@@ -12,4 +12,9 @@ type ICpLeaveCtrlV1 interface {
List(ctx context.Context, req *v1.CpLeaveListReq) (*utils.Response[[]*ent.CpLeave], error)
Report(ctx context.Context, req *v1.CpLeaveReportReq) (*utils.Response[any], error)
Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (*utils.Response[string], error)
CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (*utils.Response[any], error)
GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (*utils.Response[any], error)
GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (*utils.Response[any], error)
GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (*utils.Response[any], error)
Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (*utils.Response[any], error)
}
@@ -31,4 +31,8 @@ type ICpUserCtrlV1 interface {
// 手机登录获取验证码
LoginGetCode(ctx context.Context, req *v1.LoginGetCode) (*utils.Response[any], error)
AddByCode(ctx context.Context, req *v1.AddByCode) (*utils.Response[any], error)
// 二维码获取设备码
GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (*utils.Response[any], error)
CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (*utils.Response[any], error)
}
+2 -1
View File
@@ -126,6 +126,7 @@ type Type string
// Type values.
const (
TypeReport Type = "report"
TypeApply Type = "apply"
)
func (_type Type) String() string {
@@ -135,7 +136,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypeReport:
case TypeReport, TypeApply:
return nil
default:
return fmt.Errorf("cpleave: invalid enum value for type field: %q", _type)
+1 -1
View File
@@ -77,7 +77,7 @@ var (
{Name: "leave_id", Type: field.TypeString},
{Name: "title", Type: field.TypeString},
{Name: "status", Type: field.TypeEnum, Enums: []string{"done", "fail"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"report"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"report", "apply"}},
{Name: "desc", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "cp_user_id", Type: field.TypeInt64},
{Name: "user_id", Type: field.TypeInt64},
+1 -1
View File
@@ -21,7 +21,7 @@ func (CpLeave) Fields() []ent.Field {
field.String("leave_id").NotEmpty(),
field.String("title").NotEmpty(),
field.Enum("status").Values("done", "fail"),
field.Enum("type").Values("report"),
field.Enum("type").Values("report", "apply"),
field.Text("desc").Optional(),
}
}
+172
View File
@@ -208,3 +208,175 @@ func (v CpLeaveServiceV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq)
func NewCpLeaveServiceV1() *CpLeaveServiceV1 {
return &CpLeaveServiceV1{}
}
// CheckLeave 检查是否允许请假
func (v CpLeaveServiceV1) CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db2.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
LoginType: cpUser.LoginType, Username: cpUser.Username,
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != cpUser.Cookie {
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
err := reportApp.CheckOnceLeave()
if err != nil {
utils.OkOrPanic(err)
}
return utils.SuccessWithMsg("允许请假")
}
// GetLeaveTypes 获取请假类型列表
func (v CpLeaveServiceV1) GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db2.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
LoginType: cpUser.LoginType, Username: cpUser.Username,
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != cpUser.Cookie {
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
leaveTypes := reportApp.QueryLeaveTypes()
return utils.SuccessWithData[any](leaveTypes.Value())
}
// GetLeaveTypeCfg 查询请假类型配置
func (v CpLeaveServiceV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db2.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
LoginType: cpUser.LoginType, Username: cpUser.Username,
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != cpUser.Cookie {
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
cfg := reportApp.QueryLeaveTypeCfg(req.Code)
return utils.SuccessWithData[any](cfg.Value())
}
// GetApproveFlow 获取审批人列表
func (v CpLeaveServiceV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db2.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
LoginType: cpUser.LoginType, Username: cpUser.Username,
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != cpUser.Cookie {
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
flow := reportApp.ObtainApproveFlow(req.LeaveType, req.StartTime, req.EndTime)
return utils.SuccessWithData[any](flow.Value())
}
// Apply 提交请假申请
func (v CpLeaveServiceV1) Apply(ctx context.Context, req *v1.CpLeaveApplyReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db2.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
// 计算扣款金额
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst2.ConfigRedisCpdailyLeave)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
// 先扣款
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "今日校园请假")
// 创建请假记录
leaveRecord := db2.Global.CpLeave.Create().
SetCpUserID(req.CpUserId).
SetUserID(user.ID).
SetLeaveID(fmt.Sprintf("apply_%d", req.CpUserId)).
SetTitle(fmt.Sprintf("请假申请: %s ~ %s", req.StartTime, req.EndTime)).
SetStatus(cpleave.StatusFail).
SetType(cpleave.TypeApply).
SetDesc("正在处理中...").
SaveX(ctx)
defer func() {
if r := recover(); r != nil {
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint, "请假失败退款")
desc := fmt.Sprintf("请假失败: %v", r)
db2.Global.CpLeave.UpdateOne(leaveRecord).
SetStatus(cpleave.StatusFail).
SetDesc(desc).
ExecX(ctx)
utils.OkOrPanic(errors2.NewBusinessError(500, desc))
}
}()
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
LoginType: cpUser.LoginType, Username: cpUser.Username,
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != cpUser.Cookie {
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
reportApp.SubmitLeaveApply(ctx, req, cpUser.Username, cpUser.MobileCode)
db2.Global.CpLeave.UpdateOne(leaveRecord).
SetStatus(cpleave.StatusDone).
SetDesc("已提交").
ExecX(ctx)
return utils.SuccessWithMsg("请假申请提交成功")
}
+19
View File
@@ -240,3 +240,22 @@ func (v CpUserServiceV1) LoginUrl(ctx context.Context, req *v1.CpUserLoginUrlReq
sc := db.Global.CpSchool.Query().Where(cpschool.NameEQ(req.Name)).Limit(1).OnlyX(ctx)
return utils.SuccessWithData[any](school.GetSchoolDetail(ctx, sc.ID))
}
// GenerateQr 生成二维码UUID
func (v CpUserServiceV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) *utils.Response[any] {
uuid := utils.UUID()
expire := time.Duration(cst.RedisCpUserQrCodeExpire) * time.Second
redis.GetClient().Set(ctx, cst.RedisCpUserQrCode+uuid, "1", expire)
return utils.SuccessWithData[any](map[string]string{
"uuid": uuid,
})
}
// CheckQr 校验二维码UUID是否有效(无需登录)
func (v CpUserServiceV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) *utils.Response[any] {
val := redis.GetClient().Get(ctx, cst.RedisCpUserQrCode+req.Uuid).Val()
if val == "" {
return utils.Fail(400, "二维码无效,请重新获取")
}
return utils.SuccessWithMsg("二维码有效")
}
+70
View File
@@ -0,0 +1,70 @@
package email
import (
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
// Brand 品牌标识
type Brand string
const (
BrandDefault Brand = "default" // 老系统(若梦系统)- MD3 风格
BrandNew Brand = "new" // 新系统(天神系统)- shadcn 风格
)
// BrandConfig 品牌配置
type BrandConfig struct {
AppName string
AppCopyright string
EmailConfirmBaseUrl string
EmailResetPasswordUrl string
BalanceMigrateUrl string
}
var (
currentBrand Brand
brandConfig *BrandConfig
brandOnce sync.Once
)
// initBrand 从 config.toml 读取品牌配置(只执行一次)
func initBrand() {
ctx := gctx.New()
cfg := g.Cfg()
// app.brand: "default" 或 "new",未设置默认 "default"
brandStr := cfg.MustGet(ctx, "app.brand", "default").String()
if brandStr == "new" {
currentBrand = BrandNew
} else {
currentBrand = BrandDefault
}
brandConfig = &BrandConfig{
AppName: cfg.MustGet(ctx, "app.name", "若梦系统").String(),
AppCopyright: cfg.MustGet(ctx, "app.copyright", "© 2025 若梦系统. 保留所有权利.").String(),
EmailConfirmBaseUrl: cfg.MustGet(ctx, "app.emailConfirmBaseUrl", cfg.MustGet(ctx, "server.emailConfirmBaseUrl").String()).String(),
EmailResetPasswordUrl: cfg.MustGet(ctx, "app.emailResetPasswordUrl", cfg.MustGet(ctx, "server.emailResetPasswordUrl").String()).String(),
BalanceMigrateUrl: cfg.MustGet(ctx, "app.balanceMigrateUrl", cfg.MustGet(ctx, "server.balanceMigrateUrl").String()).String(),
}
}
// GetCurrentBrand 获取当前品牌标识
func GetCurrentBrand() Brand {
brandOnce.Do(initBrand)
return currentBrand
}
// GetBrandConfig 获取当前品牌配置
func GetBrandConfig() *BrandConfig {
brandOnce.Do(initBrand)
return brandConfig
}
// IsNewBrand 是否为新系统品牌
func IsNewBrand() bool {
return GetCurrentBrand() == BrandNew
}
+73 -78
View File
@@ -12,7 +12,6 @@ import (
email2 "likedream-goframe/internal/framework/model/email"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/utils"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
@@ -26,9 +25,7 @@ import (
var templateFiles embed.FS
type EmailServiceV1 struct {
config EmailConfig
templates *template.Template
tmplOnce sync.Once
config EmailConfig
}
type EmailConfig struct {
@@ -58,6 +55,24 @@ func NewV1() *EmailServiceV1 {
}
}
// templatePrefix 根据当前品牌返回模板文件前缀
func templatePrefix() string {
if IsNewBrand() {
return "new_"
}
return ""
}
// brandAppName 获取当前品牌的应用名称
func brandAppName() string {
return GetBrandConfig().AppName
}
// brandCopyright 获取当前品牌的版权信息
func brandCopyright() string {
return GetBrandConfig().AppCopyright
}
// SendSignupEmail 发送注册成功邮件
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
@@ -65,7 +80,8 @@ func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("欢迎注册%s", cst2.AppName)
appName := brandAppName()
subject := fmt.Sprintf("欢迎注册%s", appName)
body, err := s.renderSignupEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
@@ -108,7 +124,8 @@ func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, us
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("重置%s账户密码", cst2.AppName)
appName := brandAppName()
subject := fmt.Sprintf("重置%s账户密码", appName)
body, err := s.renderResetPasswordEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
@@ -124,9 +141,32 @@ func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, us
return utils.SuccessWithMsg("重置密码邮件发送成功")
}
// SendBalanceMigrateEmail 发送积分转移邮件
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
return utils.FailBusiness("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("%s积分转移通知", appName)
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("积分转移邮件发送成功")
}
// sendEmail 发送邮件的底层方法
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
payload := bytes.NewBuffer(nil)
e := gob.NewEncoder(payload).Encode(&email2.SendPayload{
@@ -150,44 +190,27 @@ func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string
return nil
}
// initTemplates 初始化邮件模板
func (s *EmailServiceV1) initTemplates() error {
var err error
s.templates, err = template.ParseFS(templateFiles, "templates/*.gohtml")
if err != nil {
return fmt.Errorf("解析邮件模板失败: %v", err)
}
return nil
}
// getTemplates 获取模板实例,使用sync.Once确保只初始化一次
func (s *EmailServiceV1) getTemplates() (*template.Template, error) {
var err error
s.tmplOnce.Do(func() {
err = s.initTemplates()
})
if err != nil {
return nil, err
}
return s.templates, nil
// parseTemplates 根据当前品牌解析 layout + content 模板
func parseTemplates(contentTemplate string) (*template.Template, error) {
prefix := templatePrefix()
layoutFile := fmt.Sprintf("templates/%slayout.gohtml", prefix)
contentFile := fmt.Sprintf("templates/%s%s", prefix, contentTemplate)
return template.ParseFS(templateFiles, layoutFile, contentFile)
}
// renderSignupEmailTemplate 渲染注册邮件模板
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
// 单独解析注册确认模板
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/signup_confirm.gohtml")
tmpl, err := parseTemplates("signup_confirm.gohtml")
if err != nil {
return "", fmt.Errorf("解析注册邮件模板失败: %v", err)
}
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigEmailConfirmBaseUrl)
if err != nil {
return "", err
}
data := NewSignupTemplateData(username, cst2.AppName, cst2.AppCopyright, get.String()+token)
bc := GetBrandConfig()
data := NewSignupTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailConfirmBaseUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "signup_confirm.gohtml", data)
err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
}
@@ -197,16 +220,17 @@ func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string
// renderTaskNotificationTemplate 渲染任务通知邮件模板
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status email2.TaskStatus, details map[string]string) (string, error) {
// 单独解析任务通知模板
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/task_notification.gohtml")
tmpl, err := parseTemplates("task_notification.gohtml")
if err != nil {
return "", fmt.Errorf("解析任务通知邮件模板失败: %v", err)
}
data := NewTaskNotificationTemplateData(taskName, status, details, cst2.AppName, cst2.AppCopyright)
bc := GetBrandConfig()
data := NewTaskNotificationTemplateData(taskName, status, details, bc.AppName, bc.AppCopyright)
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "task_notification.gohtml", data)
err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
}
@@ -216,20 +240,17 @@ func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
// 单独解析重置密码模板
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/reset_password.gohtml")
tmpl, err := parseTemplates("reset_password.gohtml")
if err != nil {
return "", fmt.Errorf("解析重置密码邮件模板失败: %v", err)
}
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigEmailResetPasswordUrl)
if err != nil {
return "", err
}
data := NewResetPasswordTemplateData(username, cst2.AppName, cst2.AppCopyright, get.String()+token)
bc := GetBrandConfig()
data := NewResetPasswordTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailResetPasswordUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "reset_password.gohtml", data)
err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
}
@@ -237,45 +258,19 @@ func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token
return buf.String(), nil
}
// SendBalanceMigrateEmail 发送积分转移邮件
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("%s积分转移通知", cst2.AppName)
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("积分转移邮件发送成功")
}
// renderBalanceMigrateEmailTemplate 渲染积分转移邮件模板
func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, balance int, token string) (string, error) {
// 单独解析积分转移模板
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/balance_migrate.gohtml")
tmpl, err := parseTemplates("balance_migrate.gohtml")
if err != nil {
return "", fmt.Errorf("解析积分转移邮件模板失败: %v", err)
}
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigBalanceMigrateUrl)
if err != nil {
return "", err
}
data := NewBalanceMigrateTemplateData(username, cst2.AppName, cst2.AppCopyright, balance, get.String()+token)
bc := GetBrandConfig()
data := NewBalanceMigrateTemplateData(username, bc.AppName, bc.AppCopyright, balance, bc.BalanceMigrateUrl+token)
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "balance_migrate.gohtml", data)
err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染积分转移邮件模板失败: %v", err)
}
+56 -14
View File
@@ -3,12 +3,12 @@ package email
import (
"context"
"fmt"
"likedream-goframe/internal/common/cst"
"testing"
)
func TestEmailTemplates(t *testing.T) {
service := &EmailServiceV1{}
bc := GetBrandConfig()
// 测试注册邮件模板
t.Run("signup_email_template", func(t *testing.T) {
@@ -26,17 +26,17 @@ func TestEmailTemplates(t *testing.T) {
t.Fatal("注册邮件模板中没有包含用户名")
}
// 检查是否包含网站名称常量
if !containsString(body, cst.AppName) {
t.Fatalf("注册邮件模板中没有包含网站名称: %s", cst.AppName)
// 检查是否包含品牌名称
if !containsString(body, bc.AppName) {
t.Fatalf("注册邮件模板中没有包含品牌名称: %s", bc.AppName)
}
// 检查是否包含版权信息常量
if !containsString(body, cst.AppCopyright) {
t.Fatalf("注册邮件模板中没有包含版权信息: %s", cst.AppCopyright)
// 检查是否包含版权信息
if !containsString(body, bc.AppCopyright) {
t.Fatalf("注册邮件模板中没有包含版权信息: %s", bc.AppCopyright)
}
fmt.Printf("注册邮件模板渲染成功,长度: %d\n", len(body))
fmt.Printf("注册邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
})
// 测试任务通知邮件模板
@@ -60,12 +60,56 @@ func TestEmailTemplates(t *testing.T) {
t.Fatal("任务通知邮件模板中没有包含任务名称")
}
// 检查是否包含版权信息常量
if !containsString(body, cst.AppCopyright) {
t.Fatalf("任务通知邮件模板中没有包含版权信息: %s", cst.AppCopyright)
// 检查是否包含版权信息
if !containsString(body, bc.AppCopyright) {
t.Fatalf("任务通知邮件模板中没有包含版权信息: %s", bc.AppCopyright)
}
fmt.Printf("任务通知邮件模板渲染成功,长度: %d\n", len(body))
fmt.Printf("任务通知邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
})
// 测试重置密码邮件模板
t.Run("reset_password_template", func(t *testing.T) {
body, err := service.renderResetPasswordEmailTemplate("测试用户", "test-token")
if err != nil {
t.Fatalf("渲染重置密码邮件模板失败: %v", err)
}
if body == "" {
t.Fatal("重置密码邮件模板内容为空")
}
if !containsString(body, "测试用户") {
t.Fatal("重置密码邮件模板中没有包含用户名")
}
if !containsString(body, bc.AppName) {
t.Fatalf("重置密码邮件模板中没有包含品牌名称: %s", bc.AppName)
}
fmt.Printf("重置密码邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
})
// 测试积分转移邮件模板
t.Run("balance_migrate_template", func(t *testing.T) {
body, err := service.renderBalanceMigrateEmailTemplate("测试用户", 100, "test-token")
if err != nil {
t.Fatalf("渲染积分转移邮件模板失败: %v", err)
}
if body == "" {
t.Fatal("积分转移邮件模板内容为空")
}
if !containsString(body, "测试用户") {
t.Fatal("积分转移邮件模板中没有包含用户名")
}
if !containsString(body, "100") {
t.Fatal("积分转移邮件模板中没有包含积分数量")
}
fmt.Printf("积分转移邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
})
}
@@ -86,8 +130,6 @@ func findSubstring(s, substr string) int {
func TestEmailSending(t *testing.T) {
//t.Skip("跳过真实邮件发送测试,需要SMTP配置")
// 这里可以添加真实的邮件发送测试
// 需要配置真实的SMTP信息
service := NewV1()
ctx := context.Background()
-27
View File
@@ -111,30 +111,3 @@ func NewBalanceMigrateTemplateData(username, appName, copyright string, balance
MigrateURL: migrateURL,
}
}
// TemplateType 模板类型枚举
type TemplateType string
const (
TemplateTypeSignup TemplateType = "signup" // 注册模板
TemplateTypeTaskNotification TemplateType = "task_notification" // 任务通知模板
TemplateTypeResetPassword TemplateType = "reset_password" // 重置密码模板
TemplateTypeBalanceMigrate TemplateType = "balance_migrate" // 积分转移模板
)
// GetTemplatePath 获取模板文件路径
func (t TemplateType) GetTemplatePath() string {
switch t {
case TemplateTypeSignup:
return "internal/service/email/templates/signup.gohtml"
case TemplateTypeTaskNotification:
return "internal/service/email/templates/task_notification.gohtml"
default:
return ""
}
}
// GetLayoutPath 获取布局模板路径
func GetLayoutPath() string {
return "internal/service/email/templates/layout.gohtml"
}
@@ -0,0 +1,50 @@
{{- /*gotype: likedream-goframe/internal/service/email.BalanceMigrateTemplateData*/ -}}
{{template "layout" .}}
{{define "extra-styles"}}
<style>
.balance-card {
border: 1px solid hsl(240 5.9% 90%);
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 16px 0;
background: hsl(240 4.8% 95.9%);
}
.balance-label {
font-size: 13px;
color: hsl(240 3.8% 46.1%);
margin-bottom: 4px;
}
.balance-amount {
font-size: 28px;
font-weight: 700;
color: hsl(240 10% 3.9%);
letter-spacing: -0.025em;
}
</style>
{{end}}
{{define "content"}}
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
<p>检测到你在旧系统中有积分余额,可以转移到新系统。</p>
<div class="balance-card">
<div class="balance-label">旧系统积分余额</div>
<div class="balance-amount">{{.Balance}}</div>
</div>
<div style="text-align: center; margin: 24px 0;">
<a href="{{.MigrateURL}}" class="btn-warning">转移积分</a>
</div>
<div class="link-text">{{.MigrateURL}}</div>
<div class="alert alert-warning">
<p><strong>注意事项</strong></p>
<p>• 转移后旧系统积分将清空,请确认后操作</p>
<p>• 每个账户仅可转移一次</p>
<p>• 链接有效期 24 小时</p>
</div>
{{end}}
@@ -0,0 +1,189 @@
{{- /*gotype: likedream-goframe/internal/service/email.BaseTemplateData*/ -}}
{{define "layout"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: hsl(240 10% 3.9%);
background: hsl(0 0% 96%);
padding: 40px 16px;
}
.container {
max-width: 560px;
margin: 0 auto;
background: hsl(0 0% 100%);
border-radius: 12px;
border: 1px solid hsl(240 5.9% 90%);
overflow: hidden;
}
.header {
padding: 32px 32px 24px;
border-bottom: 1px solid hsl(240 5.9% 90%);
}
.header-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.025em;
color: hsl(240 10% 3.9%);
}
.header-desc {
font-size: 14px;
color: hsl(240 3.8% 46.1%);
margin-top: 4px;
}
.content {
padding: 24px 32px 32px;
}
.content p {
font-size: 14px;
line-height: 1.7;
margin-bottom: 16px;
color: hsl(240 3.8% 46.1%);
}
.content strong {
color: hsl(240 10% 3.9%);
font-weight: 500;
}
.user-badge {
display: inline-block;
font-weight: 600;
color: hsl(240 5.9% 10%);
}
.btn-primary {
display: inline-block;
padding: 10px 24px;
background: hsl(240 5.9% 10%);
color: hsl(0 0% 98%) !important;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
transition: opacity 0.2s;
}
.btn-destructive {
display: inline-block;
padding: 10px 24px;
background: hsl(0 84.2% 60.2%);
color: hsl(0 0% 98%) !important;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.btn-warning {
display: inline-block;
padding: 10px 24px;
background: hsl(24.6 95% 53.1%);
color: hsl(0 0% 98%) !important;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.link-text {
font-size: 12px;
color: hsl(240 3.8% 46.1%);
word-break: break-all;
background: hsl(240 4.8% 95.9%);
padding: 8px 12px;
border-radius: 6px;
margin-top: 12px;
}
.alert {
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
margin: 16px 0;
border: 1px solid;
}
.alert-info {
background: hsl(214 95% 97%);
border-color: hsl(214 82% 90%);
color: hsl(214 60% 40%);
}
.alert-warning {
background: hsl(48 96% 97%);
border-color: hsl(48 80% 85%);
color: hsl(32 60% 35%);
}
.alert-destructive {
background: hsl(0 86% 97%);
border-color: hsl(0 74% 90%);
color: hsl(0 60% 40%);
}
.alert p {
margin: 4px 0;
font-size: 13px;
}
.separator {
border: none;
border-top: 1px solid hsl(240 5.9% 90%);
margin: 20px 0;
}
.footer {
padding: 20px 32px;
text-align: center;
border-top: 1px solid hsl(240 5.9% 90%);
background: hsl(240 4.8% 95.9%);
}
.footer p {
font-size: 12px;
color: hsl(240 3.8% 46.1%);
margin: 2px 0;
line-height: 1.5;
}
@media (max-width: 640px) {
body { padding: 16px 8px; }
.header, .content { padding-left: 20px; padding-right: 20px; }
.footer { padding: 16px 20px; }
}
</style>
{{block "extra-styles" .}}{{end}}
</head>
<body>
<div class="container">
<div class="header">
<div class="header-title">{{.HeaderTitle}}</div>
{{if .AppName}}<div class="header-desc">{{.AppName}}</div>{{end}}
</div>
<div class="content">
{{block "content" .}}{{end}}
</div>
<div class="footer">
<p>{{.AppCopyright}}</p>
<p>这是一封自动发送的邮件,请勿直接回复。</p>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,25 @@
{{- /*gotype: likedream-goframe/internal/service/email.ResetPasswordTemplateData*/ -}}
{{template "layout" .}}
{{define "extra-styles"}}{{end}}
{{define "content"}}
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
<p>我们收到了重置 <strong>{{.AppName}}</strong> 账户密码的请求。点击下方按钮设置新密码:</p>
<div style="text-align: center; margin: 24px 0;">
<a href="{{.ResetURL}}" class="btn-destructive">重置密码</a>
</div>
<div class="link-text">{{.ResetURL}}</div>
<div class="alert alert-info">
<p>⏰ 链接有效期为 1 小时。</p>
</div>
<div class="alert alert-destructive">
<p><strong>安全提醒</strong></p>
<p>如果这不是你本人的操作,请忽略此邮件,账户不会受到影响。</p>
</div>
{{end}}
@@ -0,0 +1,23 @@
{{- /*gotype: likedream-goframe/internal/service/email.SignupTemplateData*/ -}}
{{template "layout" .}}
{{define "extra-styles"}}{{end}}
{{define "content"}}
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
<p>感谢注册 <strong>{{.AppName}}</strong>。请点击下方按钮完成邮箱验证:</p>
<div style="text-align: center; margin: 24px 0;">
<a href="{{.VerificationURL}}" class="btn-primary">验证邮箱</a>
</div>
<div class="link-text">{{.VerificationURL}}</div>
<div class="alert alert-info">
<p>⏰ 链接有效期为 24 小时,过期需重新注册。</p>
</div>
<hr class="separator">
<p>如有问题请联系技术支持。</p>
{{end}}
@@ -0,0 +1,70 @@
{{- /*gotype: likedream-goframe/internal/service/email.TaskNotificationTemplateData*/ -}}
{{template "layout" .}}
{{define "extra-styles"}}
<style>
.task-card {
border: 1px solid hsl(240 5.9% 90%);
border-radius: 8px;
overflow: hidden;
margin: 16px 0;
}
.task-card-header {
padding: 12px 16px;
background: hsl(240 4.8% 95.9%);
border-bottom: 1px solid hsl(240 5.9% 90%);
display: flex;
justify-content: space-between;
align-items: center;
}
.task-card-header span {
font-size: 14px;
font-weight: 500;
color: hsl(240 10% 3.9%);
}
.status-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.task-card-body {
padding: 0;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 10px 16px;
font-size: 13px;
border-bottom: 1px solid hsl(240 5.9% 90%);
}
.detail-row:last-child { border-bottom: none; }
.detail-key { color: hsl(240 3.8% 46.1%); }
.detail-val { color: hsl(240 10% 3.9%); font-weight: 500; }
</style>
{{end}}
{{define "content"}}
<div class="task-card">
<div class="task-card-header">
<span>{{.TaskName}}</span>
<span class="status-tag" style="background: {{.Status.GetStatusColor}}20; color: {{.Status.GetStatusColor}};">{{.StatusText}}</span>
</div>
{{if .Details}}
<div class="task-card-body">
{{range $key, $value := .Details}}
<div class="detail-row">
<span class="detail-key">{{$key}}</span>
<span class="detail-val">{{$value}}</span>
</div>
{{end}}
</div>
{{end}}
</div>
<div class="alert alert-info">
<p>如需查看更多详情,请登录系统查看任务执行记录。</p>
</div>
{{end}}