feat:添加用户协议文档管理功能
This commit is contained in:
1 parent
6dc69944d1
commit
a1f637fb2a
37 files changed
+2287
-17
No files matched your search
@@ -18,6 +18,15 @@ type IAdminV1 interface {
|
||||
AdminUserUpdateStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (res *v1.AdminUserUpdateStatusRes, err error)
|
||||
AdminUserResetPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (res *v1.AdminUserResetPasswordRes, err error)
|
||||
AdminUserExport(ctx context.Context, req *v1.AdminUserExportReq) (res *v1.AdminUserExportRes, err error)
|
||||
AgreementCreate(ctx context.Context, req *v1.AgreementCreateReq) (res *v1.AgreementCreateRes, err error)
|
||||
AgreementList(ctx context.Context, req *v1.AgreementListReq) (res *v1.AgreementListRes, err error)
|
||||
AgreementGet(ctx context.Context, req *v1.AgreementGetReq) (res *v1.AgreementGetRes, err error)
|
||||
AgreementUpdate(ctx context.Context, req *v1.AgreementUpdateReq) (res *v1.AgreementUpdateRes, err error)
|
||||
AgreementDelete(ctx context.Context, req *v1.AgreementDeleteReq) (res *v1.AgreementDeleteRes, err error)
|
||||
AgreementPublish(ctx context.Context, req *v1.AgreementPublishReq) (res *v1.AgreementPublishRes, err error)
|
||||
AgreementAcceptanceList(ctx context.Context, req *v1.AgreementAcceptanceListReq) (res *v1.AgreementAcceptanceListRes, err error)
|
||||
AdminAgreementPending(ctx context.Context, req *v1.AdminAgreementPendingReq) (res *v1.AdminAgreementPendingRes, err error)
|
||||
AdminAgreementAccept(ctx context.Context, req *v1.AdminAgreementAcceptReq) (res *v1.AdminAgreementAcceptRes, err error)
|
||||
AuditConfigGet(ctx context.Context, req *v1.AuditConfigGetReq) (res *v1.AuditConfigGetRes, err error)
|
||||
AuditConfigUpdate(ctx context.Context, req *v1.AuditConfigUpdateReq) (res *v1.AuditConfigUpdateRes, err error)
|
||||
OperationLogList(ctx context.Context, req *v1.OperationLogListReq) (res *v1.OperationLogListRes, err error)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// === 用户协议管理(管理后台 CRUD) ===
|
||||
|
||||
type AgreementCreateReq struct {
|
||||
g.Meta `path:"/agreements" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"创建协议版本"`
|
||||
Code string `json:"code" v:"required|length:1,50" dc:"协议标识码"`
|
||||
Version string `json:"version" v:"required|length:1,50" dc:"版本号"`
|
||||
Title string `json:"title" v:"required|length:1,200" dc:"协议标题"`
|
||||
Content string `json:"content" v:"required" dc:"协议正文(Markdown)"`
|
||||
Summary string `json:"summary" v:"max-length:500" dc:"版本变更摘要"`
|
||||
ForceAccept *bool `json:"force_accept" d:"true" dc:"是否强制用户接受"`
|
||||
}
|
||||
|
||||
type AgreementCreateRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
type AgreementListReq struct {
|
||||
g.Meta `path:"/agreements" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议版本列表"`
|
||||
Page int `json:"page" in:"query" d:"1"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20"`
|
||||
Code string `json:"code" in:"query" dc:"协议标识码筛选"`
|
||||
Status string `json:"status" in:"query" dc:"状态筛选"`
|
||||
}
|
||||
|
||||
type AgreementItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgreementListRes struct {
|
||||
List []*AgreementItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type AgreementGetReq struct {
|
||||
g.Meta `path:"/agreements/{id}" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议版本详情"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
}
|
||||
|
||||
type AgreementGetRes struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgreementUpdateReq struct {
|
||||
g.Meta `path:"/agreements/{id}" method:"put" mime:"json" tags:"管理后台-用户协议" summary:"更新协议版本"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
Title string `json:"title" v:"required|length:1,200" dc:"协议标题"`
|
||||
Content string `json:"content" v:"required" dc:"协议正文(Markdown)"`
|
||||
Summary string `json:"summary" v:"max-length:500" dc:"版本变更摘要"`
|
||||
ForceAccept *bool `json:"force_accept" dc:"是否强制用户接受"`
|
||||
}
|
||||
|
||||
type AgreementUpdateRes struct{}
|
||||
|
||||
type AgreementDeleteReq struct {
|
||||
g.Meta `path:"/agreements/{id}" method:"delete" mime:"json" tags:"管理后台-用户协议" summary:"删除协议版本"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
}
|
||||
|
||||
type AgreementDeleteRes struct{}
|
||||
|
||||
type AgreementPublishReq struct {
|
||||
g.Meta `path:"/agreements/{id}/publish" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"发布协议版本"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
}
|
||||
|
||||
type AgreementPublishRes struct{}
|
||||
|
||||
// === 协议接受记录(管理后台审计) ===
|
||||
|
||||
type AgreementAcceptanceListReq struct {
|
||||
g.Meta `path:"/agreements/{id}/acceptances" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议接受记录"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
Page int `json:"page" in:"query" d:"1"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20"`
|
||||
}
|
||||
|
||||
type AgreementAcceptanceItem struct {
|
||||
Id int64 `json:"id"`
|
||||
UserType string `json:"user_type"`
|
||||
UserId int64 `json:"user_id"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AgreementAcceptanceListRes struct {
|
||||
List []*AgreementAcceptanceItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// === 管理员待接受协议 ===
|
||||
|
||||
type AdminAgreementPendingReq struct {
|
||||
g.Meta `path:"/agreements/pending" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"当前管理员待接受的协议"`
|
||||
}
|
||||
|
||||
type PendingAgreementItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type AdminAgreementPendingRes struct {
|
||||
List []*PendingAgreementItem `json:"list"`
|
||||
}
|
||||
|
||||
type AdminAgreementAcceptReq struct {
|
||||
g.Meta `path:"/agreements/accept" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"管理员接受协议"`
|
||||
AgreementIds []int64 `json:"agreement_ids" v:"required|min-length:1" dc:"协议版本ID列表"`
|
||||
}
|
||||
|
||||
type AdminAgreementAcceptRes struct{}
|
||||
@@ -23,6 +23,15 @@ type AdminLoginRes struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
PendingAgreements []*LoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表
|
||||
}
|
||||
|
||||
// LoginPendingAgreement 登录时返回的待接受协议信息
|
||||
type LoginPendingAgreement struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AdminLogoutReq 管理后台登出请求
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type ITenantV1 interface {
|
||||
TenantAgreementPending(ctx context.Context, req *v1.TenantAgreementPendingReq) (res *v1.TenantAgreementPendingRes, err error)
|
||||
TenantAgreementAccept(ctx context.Context, req *v1.TenantAgreementAcceptReq) (res *v1.TenantAgreementAcceptRes, err error)
|
||||
AgreementCurrentList(ctx context.Context, req *v1.AgreementCurrentListReq) (res *v1.AgreementCurrentListRes, err error)
|
||||
AgreementCurrentGet(ctx context.Context, req *v1.AgreementCurrentGetReq) (res *v1.AgreementCurrentGetRes, err error)
|
||||
TenantApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (res *v1.TenantApiKeyListRes, err error)
|
||||
TenantApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateReq) (res *v1.TenantApiKeyCreateRes, err error)
|
||||
TenantApiKeyDelete(ctx context.Context, req *v1.TenantApiKeyDeleteReq) (res *v1.TenantApiKeyDeleteRes, err error)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// === 用户协议(租户控制台) ===
|
||||
|
||||
// --- 租户用户待接受协议 ---
|
||||
|
||||
type TenantAgreementPendingReq struct {
|
||||
g.Meta `path:"/agreements/pending" method:"get" mime:"json" tags:"租户控制台-用户协议" summary:"当前用户待接受的协议"`
|
||||
}
|
||||
|
||||
type TenantPendingAgreementItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type TenantAgreementPendingRes struct {
|
||||
List []*TenantPendingAgreementItem `json:"list"`
|
||||
}
|
||||
|
||||
type TenantAgreementAcceptReq struct {
|
||||
g.Meta `path:"/agreements/accept" method:"post" mime:"json" tags:"租户控制台-用户协议" summary:"用户接受协议"`
|
||||
AgreementIds []int64 `json:"agreement_ids" v:"required|min-length:1" dc:"协议版本ID列表"`
|
||||
}
|
||||
|
||||
type TenantAgreementAcceptRes struct{}
|
||||
|
||||
// --- 公开端点(登录/注册页面使用,无需认证) ---
|
||||
|
||||
type AgreementCurrentListReq struct {
|
||||
g.Meta `path:"/agreements/current" method:"get" mime:"json" tags:"公开-用户协议" summary:"所有当前生效的协议列表" group:"public" middleware:"-"`
|
||||
}
|
||||
|
||||
type PublicAgreementItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
type AgreementCurrentListRes struct {
|
||||
List []*PublicAgreementItem `json:"list"`
|
||||
}
|
||||
|
||||
type AgreementCurrentGetReq struct {
|
||||
g.Meta `path:"/agreements/current/{code}" method:"get" mime:"json" tags:"公开-用户协议" summary:"按标识码获取当前协议详情" group:"public" middleware:"-"`
|
||||
Code string `json:"code" in:"path" v:"required|length:1,50"`
|
||||
}
|
||||
|
||||
type AgreementCurrentGetRes struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Content string `json:"content"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
+11
-1
@@ -30,6 +30,7 @@ type TenantRegisterRes struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表
|
||||
}
|
||||
|
||||
// TenantLoginReq 租户登录请求
|
||||
@@ -59,7 +60,16 @@ type TenantLoginRes struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
MaintenanceInfo *LoginMaintenanceInfo `json:"maintenance_info,omitempty"`
|
||||
MaintenanceInfo *LoginMaintenanceInfo `json:"maintenance_info,omitempty"`
|
||||
PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表
|
||||
}
|
||||
|
||||
// TenantLoginPendingAgreement 登录时返回的待接受协议信息
|
||||
type TenantLoginPendingAgreement struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// LoginMaintenanceInfo 维护模式信息
|
||||
|
||||
@@ -198,6 +198,12 @@ const (
|
||||
// Username validation
|
||||
CodeInvalidUsername = 10089
|
||||
|
||||
// Agreement errors
|
||||
CodeAgreementNotFound = 10092
|
||||
CodeAgreementNotDraft = 10093
|
||||
CodeAgreementVersionExists = 10094
|
||||
CodeAgreementCodeInvalid = 10095
|
||||
|
||||
// Demo mode
|
||||
CodeDemoModeRestricted = 10403
|
||||
|
||||
@@ -332,6 +338,12 @@ const (
|
||||
|
||||
// Username validation
|
||||
MsgInvalidUsername = "用户名仅支持英文字母和数字,不能为纯数字"
|
||||
|
||||
// Agreement errors
|
||||
MsgAgreementNotFound = "协议版本不存在"
|
||||
MsgAgreementNotDraft = "仅草稿状态的协议可修改或删除"
|
||||
MsgAgreementVersionExists = "该协议类型的此版本号已存在"
|
||||
MsgAgreementCodeInvalid = "协议标识码无效"
|
||||
)
|
||||
|
||||
// Demo mode messages
|
||||
@@ -354,3 +366,15 @@ const (
|
||||
MsgUpdateRollbackFailed = "回滚失败"
|
||||
MsgUpdateNotSupported = "当前部署模式不支持自动更新"
|
||||
)
|
||||
|
||||
// Agreement code constants
|
||||
const (
|
||||
AgreementCodeTerms = "terms" // 用户协议/服务条款
|
||||
AgreementCodePrivacy = "privacy" // 隐私政策
|
||||
)
|
||||
|
||||
// ValidAgreementCodes 有效协议标识码集合(创建时校验)
|
||||
var ValidAgreementCodes = map[string]string{
|
||||
AgreementCodeTerms: "用户协议",
|
||||
AgreementCodePrivacy: "隐私政策",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) AgreementCreate(ctx context.Context, req *v1.AgreementCreateReq) (res *v1.AgreementCreateRes, err error) {
|
||||
return service.Admin().CreateAgreement(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementList(ctx context.Context, req *v1.AgreementListReq) (res *v1.AgreementListRes, err error) {
|
||||
return service.Admin().ListAgreements(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementGet(ctx context.Context, req *v1.AgreementGetReq) (res *v1.AgreementGetRes, err error) {
|
||||
return service.Admin().GetAgreement(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementUpdate(ctx context.Context, req *v1.AgreementUpdateReq) (res *v1.AgreementUpdateRes, err error) {
|
||||
return service.Admin().UpdateAgreement(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementDelete(ctx context.Context, req *v1.AgreementDeleteReq) (res *v1.AgreementDeleteRes, err error) {
|
||||
return service.Admin().DeleteAgreement(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementPublish(ctx context.Context, req *v1.AgreementPublishReq) (res *v1.AgreementPublishRes, err error) {
|
||||
return service.Admin().PublishAgreement(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementAcceptanceList(ctx context.Context, req *v1.AgreementAcceptanceListReq) (res *v1.AgreementAcceptanceListRes, err error) {
|
||||
return service.Admin().ListAgreementAcceptances(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminAgreementPending(ctx context.Context, req *v1.AdminAgreementPendingReq) (res *v1.AdminAgreementPendingRes, err error) {
|
||||
return service.Admin().ListAdminPendingAgreements(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminAgreementAccept(ctx context.Context, req *v1.AdminAgreementAcceptReq) (res *v1.AdminAgreementAcceptRes, err error) {
|
||||
return service.Admin().AcceptAdminAgreements(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantAgreementPending(ctx context.Context, req *v1.TenantAgreementPendingReq) (res *v1.TenantAgreementPendingRes, err error) {
|
||||
return service.Tenant().ListTenantPendingAgreements(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantAgreementAccept(ctx context.Context, req *v1.TenantAgreementAcceptReq) (res *v1.TenantAgreementAcceptRes, err error) {
|
||||
return service.Tenant().AcceptTenantAgreements(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementCurrentList(ctx context.Context, req *v1.AgreementCurrentListReq) (res *v1.AgreementCurrentListRes, err error) {
|
||||
return service.Tenant().ListCurrentAgreements(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AgreementCurrentGet(ctx context.Context, req *v1.AgreementCurrentGetReq) (res *v1.AgreementCurrentGetRes, err error) {
|
||||
return service.Tenant().GetCurrentAgreementByCode(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysAgreementAcceptancesDao is the data access object for the table sys_agreement_acceptances.
|
||||
type SysAgreementAcceptancesDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysAgreementAcceptancesColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances.
|
||||
type SysAgreementAcceptancesColumns struct {
|
||||
Id string //
|
||||
AgreementId string // 关联协议版本ID
|
||||
UserType string // 用户类型
|
||||
UserId string // 用户ID
|
||||
IpAddress string // IP地址
|
||||
UserAgent string // User-Agent
|
||||
CreatedAt string //
|
||||
}
|
||||
|
||||
// sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances.
|
||||
var sysAgreementAcceptancesColumns = SysAgreementAcceptancesColumns{
|
||||
Id: "id",
|
||||
AgreementId: "agreement_id",
|
||||
UserType: "user_type",
|
||||
UserId: "user_id",
|
||||
IpAddress: "ip_address",
|
||||
UserAgent: "user_agent",
|
||||
CreatedAt: "created_at",
|
||||
}
|
||||
|
||||
// NewSysAgreementAcceptancesDao creates and returns a new DAO object for table data access.
|
||||
func NewSysAgreementAcceptancesDao(handlers ...gdb.ModelHandler) *SysAgreementAcceptancesDao {
|
||||
return &SysAgreementAcceptancesDao{
|
||||
group: "default",
|
||||
table: "sys_agreement_acceptances",
|
||||
columns: sysAgreementAcceptancesColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysAgreementAcceptancesDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysAgreementAcceptancesDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysAgreementAcceptancesDao) Columns() SysAgreementAcceptancesColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysAgreementAcceptancesDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO.
|
||||
func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
func (dao *SysAgreementAcceptancesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysAgreementsDao is the data access object for the table sys_agreements.
|
||||
type SysAgreementsDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysAgreementsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysAgreementsColumns defines and stores column names for the table sys_agreements.
|
||||
type SysAgreementsColumns struct {
|
||||
Id string //
|
||||
Code string // 协议标识码
|
||||
Version string // 版本号
|
||||
Title string // 协议标题
|
||||
Content string // 协议正文(Markdown)
|
||||
Summary string // 版本变更摘要
|
||||
Status string // 状态
|
||||
IsCurrent string // 是否为当前生效版本
|
||||
ForceAccept string // 是否强制用户接受
|
||||
PublishedAt string // 发布时间
|
||||
CreatedBy string // 创建的管理员ID
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
}
|
||||
|
||||
// sysAgreementsColumns holds the columns for the table sys_agreements.
|
||||
var sysAgreementsColumns = SysAgreementsColumns{
|
||||
Id: "id",
|
||||
Code: "code",
|
||||
Version: "version",
|
||||
Title: "title",
|
||||
Content: "content",
|
||||
Summary: "summary",
|
||||
Status: "status",
|
||||
IsCurrent: "is_current",
|
||||
ForceAccept: "force_accept",
|
||||
PublishedAt: "published_at",
|
||||
CreatedBy: "created_by",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewSysAgreementsDao creates and returns a new DAO object for table data access.
|
||||
func NewSysAgreementsDao(handlers ...gdb.ModelHandler) *SysAgreementsDao {
|
||||
return &SysAgreementsDao{
|
||||
group: "default",
|
||||
table: "sys_agreements",
|
||||
columns: sysAgreementsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysAgreementsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysAgreementsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysAgreementsDao) Columns() SysAgreementsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysAgreementsDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO.
|
||||
func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
func (dao *SysAgreementsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysAgreementAcceptancesDao is the data access object for the table sys_agreement_acceptances.
|
||||
type sysAgreementAcceptancesDao struct {
|
||||
*internal.SysAgreementAcceptancesDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysAgreementAcceptances is a globally accessible object for table sys_agreement_acceptances operations.
|
||||
SysAgreementAcceptances = sysAgreementAcceptancesDao{internal.NewSysAgreementAcceptancesDao()}
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysAgreementsDao is the data access object for the table sys_agreements.
|
||||
type sysAgreementsDao struct {
|
||||
*internal.SysAgreementsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysAgreements is a globally accessible object for table sys_agreements operations.
|
||||
SysAgreements = sysAgreementsDao{internal.NewSysAgreementsDao()}
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// CreateAgreement 创建协议版本
|
||||
func (s *sAdmin) CreateAgreement(ctx context.Context, req *v1.AgreementCreateReq) (*v1.AgreementCreateRes, error) {
|
||||
if _, ok := consts.ValidAgreementCodes[req.Code]; !ok {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementCodeInvalid, consts.MsgAgreementCodeInvalid)
|
||||
}
|
||||
|
||||
// 检查 code+version 唯一性
|
||||
count, err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("code", req.Code).
|
||||
Where("version", req.Version).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementVersionExists, consts.MsgAgreementVersionExists)
|
||||
}
|
||||
|
||||
forceAccept := true
|
||||
if req.ForceAccept != nil {
|
||||
forceAccept = *req.ForceAccept
|
||||
}
|
||||
|
||||
result, err := dao.SysAgreements.Ctx(ctx).Data(do.SysAgreements{
|
||||
Code: req.Code,
|
||||
Version: req.Version,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Summary: req.Summary,
|
||||
Status: "draft",
|
||||
IsCurrent: false,
|
||||
ForceAccept: forceAccept,
|
||||
CreatedBy: common.GetCtxUserID(ctx),
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &v1.AgreementCreateRes{Id: id}, nil
|
||||
}
|
||||
|
||||
// ListAgreements 协议版本列表
|
||||
func (s *sAdmin) ListAgreements(ctx context.Context, req *v1.AgreementListReq) (*v1.AgreementListRes, error) {
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
query := dao.SysAgreements.Ctx(ctx)
|
||||
if req.Code != "" {
|
||||
query = query.Where("code", req.Code)
|
||||
}
|
||||
if req.Status != "" {
|
||||
query = query.Where("status", req.Status)
|
||||
}
|
||||
|
||||
var total int
|
||||
rows := make([]*v1.AgreementItem, 0)
|
||||
err := query.OrderDesc("created_at").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&rows, &total, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AgreementListRes{
|
||||
List: rows,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAgreement 协议版本详情
|
||||
func (s *sAdmin) GetAgreement(ctx context.Context, req *v1.AgreementGetReq) (*v1.AgreementGetRes, error) {
|
||||
var res *v1.AgreementGetRes
|
||||
err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&res)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res == nil {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateAgreement 更新协议版本(仅 draft)
|
||||
func (s *sAdmin) UpdateAgreement(ctx context.Context, req *v1.AgreementUpdateReq) (*v1.AgreementUpdateRes, error) {
|
||||
var agr *struct {
|
||||
Id int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agr == nil {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound)
|
||||
}
|
||||
if agr.Status != "draft" {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft)
|
||||
}
|
||||
|
||||
data := do.SysAgreements{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Summary: req.Summary,
|
||||
}
|
||||
if req.ForceAccept != nil {
|
||||
data.ForceAccept = *req.ForceAccept
|
||||
}
|
||||
|
||||
_, err = dao.SysAgreements.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AgreementUpdateRes{}, nil
|
||||
}
|
||||
|
||||
// DeleteAgreement 删除协议版本(仅 draft)
|
||||
func (s *sAdmin) DeleteAgreement(ctx context.Context, req *v1.AgreementDeleteReq) (*v1.AgreementDeleteRes, error) {
|
||||
var agr *struct {
|
||||
Id int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agr == nil {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound)
|
||||
}
|
||||
if agr.Status != "draft" {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft)
|
||||
}
|
||||
|
||||
_, err = dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Delete()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AgreementDeleteRes{}, nil
|
||||
}
|
||||
|
||||
// PublishAgreement 发布协议版本
|
||||
func (s *sAdmin) PublishAgreement(ctx context.Context, req *v1.AgreementPublishReq) (*v1.AgreementPublishRes, error) {
|
||||
var agr *struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agr == nil {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound)
|
||||
}
|
||||
if agr.Status == "published" {
|
||||
return &v1.AgreementPublishRes{}, nil
|
||||
}
|
||||
if agr.Status != "draft" {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft)
|
||||
}
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return common.PublishAgreementTx(ctx, tx, agr.Id, agr.Code)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AgreementPublishRes{}, nil
|
||||
}
|
||||
|
||||
// ListAgreementAcceptances 协议接受记录列表
|
||||
func (s *sAdmin) ListAgreementAcceptances(ctx context.Context, req *v1.AgreementAcceptanceListReq) (*v1.AgreementAcceptanceListRes, error) {
|
||||
// 先检查协议是否存在
|
||||
var agr *struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agr == nil {
|
||||
return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound)
|
||||
}
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
var total int
|
||||
rows := make([]*v1.AgreementAcceptanceItem, 0)
|
||||
err = dao.SysAgreementAcceptances.Ctx(ctx).
|
||||
Where("agreement_id", req.Id).
|
||||
OrderDesc("created_at").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&rows, &total, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AgreementAcceptanceListRes{
|
||||
List: rows,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAdminPendingAgreements 当前管理员待接受的协议
|
||||
func (s *sAdmin) ListAdminPendingAgreements(ctx context.Context, req *v1.AdminAgreementPendingReq) (*v1.AdminAgreementPendingRes, error) {
|
||||
userID := common.GetCtxUserID(ctx)
|
||||
list, err := common.GetPendingAgreements(ctx, "admin", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*v1.PendingAgreementItem, 0, len(list))
|
||||
for _, a := range list {
|
||||
items = append(items, &v1.PendingAgreementItem{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
Content: a.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.AdminAgreementPendingRes{List: items}, nil
|
||||
}
|
||||
|
||||
// AcceptAdminAgreements 管理员接受协议
|
||||
func (s *sAdmin) AcceptAdminAgreements(ctx context.Context, req *v1.AdminAgreementAcceptReq) (*v1.AdminAgreementAcceptRes, error) {
|
||||
userID := common.GetCtxUserID(ctx)
|
||||
r := g.RequestFromCtx(ctx)
|
||||
ipAddress := r.GetClientIp()
|
||||
userAgent := r.GetHeader("User-Agent")
|
||||
|
||||
err := common.AcceptAgreements(ctx, "admin", userID, req.AgreementIds, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AdminAgreementAcceptRes{}, nil
|
||||
}
|
||||
@@ -138,6 +138,22 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
res.User.DisplayName = user.DisplayName
|
||||
res.User.Role = user.Role
|
||||
|
||||
// 检查待接受协议
|
||||
if common.Config().GetBool(ctx, "agreement_enabled") {
|
||||
if pending, err := common.GetPendingAgreements(ctx, "admin", user.Id); err == nil && len(pending) > 0 {
|
||||
items := make([]*v1.LoginPendingAgreement, 0, len(pending))
|
||||
for _, a := range pending {
|
||||
items = append(items, &v1.LoginPendingAgreement{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
})
|
||||
}
|
||||
res.PendingAgreements = items
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// PendingAgreementInfo 待接受协议信息
|
||||
type PendingAgreementInfo struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CurrentAgreementItem 当前生效协议摘要
|
||||
type CurrentAgreementItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentAgreementDetail 当前生效协议详情
|
||||
type CurrentAgreementDetail struct {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Content string `json:"content"`
|
||||
ForceAccept bool `json:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetPendingAgreements 获取用户待接受的协议列表
|
||||
func GetPendingAgreements(ctx context.Context, userType string, userID int64) ([]*PendingAgreementInfo, error) {
|
||||
rows := make([]*PendingAgreementInfo, 0)
|
||||
err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("is_current", true).
|
||||
Where("status", "published").
|
||||
Where("force_accept", true).
|
||||
Where(fmt.Sprintf(
|
||||
"id NOT IN (SELECT agreement_id FROM sys_agreement_acceptances WHERE user_type = '%s' AND user_id = %d)",
|
||||
userType, userID,
|
||||
)).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// HasPendingAgreements 快速检查是否有待接受协议
|
||||
func HasPendingAgreements(ctx context.Context, userType string, userID int64) bool {
|
||||
count, err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("is_current", true).
|
||||
Where("status", "published").
|
||||
Where("force_accept", true).
|
||||
Where(fmt.Sprintf(
|
||||
"id NOT IN (SELECT agreement_id FROM sys_agreement_acceptances WHERE user_type = '%s' AND user_id = %d)",
|
||||
userType, userID,
|
||||
)).
|
||||
Count()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// AcceptAgreements 批量接受协议
|
||||
func AcceptAgreements(ctx context.Context, userType string, userID int64, agreementIDs []int64, ipAddress, userAgent string) error {
|
||||
for _, aid := range agreementIDs {
|
||||
var agr *struct {
|
||||
Id int64 `json:"id"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("id", aid).
|
||||
Where("is_current", true).
|
||||
Where("status", "published").
|
||||
Scan(&agr)
|
||||
if err = IgnoreScanNoRows(err); err != nil {
|
||||
return err
|
||||
}
|
||||
if agr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已接受(幂等)
|
||||
existCount, err := dao.SysAgreementAcceptances.Ctx(ctx).
|
||||
Where("agreement_id", aid).
|
||||
Where("user_type", userType).
|
||||
Where("user_id", userID).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existCount > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = dao.SysAgreementAcceptances.Ctx(ctx).Data(do.SysAgreementAcceptances{
|
||||
AgreementId: aid,
|
||||
UserType: userType,
|
||||
UserId: userID,
|
||||
IpAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentAgreements 获取所有当前生效的协议列表(公开)
|
||||
func GetCurrentAgreements(ctx context.Context) ([]*CurrentAgreementItem, error) {
|
||||
rows := make([]*CurrentAgreementItem, 0)
|
||||
err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("is_current", true).
|
||||
Where("status", "published").
|
||||
Fields("id, code, title, version, force_accept, published_at").
|
||||
OrderAsc("code").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetCurrentAgreementByCode 按 code 获取当前生效协议详情(公开)
|
||||
func GetCurrentAgreementByCode(ctx context.Context, code string) (*CurrentAgreementDetail, error) {
|
||||
var row *CurrentAgreementDetail
|
||||
err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("code", code).
|
||||
Where("is_current", true).
|
||||
Where("status", "published").
|
||||
Scan(&row)
|
||||
if err = IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// PublishAgreementTx 在事务中发布协议(旧 current 归档,新版本设为 current)
|
||||
func PublishAgreementTx(ctx context.Context, tx gdb.TX, agreementID int64, code string) error {
|
||||
// 旧 current 归档
|
||||
_, err := dao.SysAgreements.Ctx(ctx).
|
||||
Where("code", code).
|
||||
Where("is_current", true).
|
||||
Where("id !=", agreementID).
|
||||
Data(g.Map{
|
||||
"is_current": false,
|
||||
"status": "archived",
|
||||
"updated_at": gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 新版本发布
|
||||
_, err = dao.SysAgreements.Ctx(ctx).
|
||||
Where("id", agreementID).
|
||||
Data(g.Map{
|
||||
"status": "published",
|
||||
"is_current": true,
|
||||
"published_at": gtime.Now(),
|
||||
"updated_at": gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
@@ -45,6 +45,7 @@ var Categories = []SettingCategory{
|
||||
{Key: "channel", Label: "渠道配置", Icon: "channel", Order: 9},
|
||||
{Key: "storage", Label: "存储配置", Icon: "cloud", Order: 10},
|
||||
{Key: "data_governance", Label: "数据治理", Icon: "database", Order: 11},
|
||||
{Key: "agreement", Label: "用户协议", Icon: "document", Order: 12},
|
||||
}
|
||||
|
||||
// Registry is the central definition of all configuration items.
|
||||
@@ -254,6 +255,10 @@ var Registry = []SettingDef{
|
||||
Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"},
|
||||
{Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance",
|
||||
Label: "启用文件保留期检查"},
|
||||
|
||||
// ── Agreement (用户协议) ──
|
||||
{Key: "agreement_enabled", Type: SettingTypeBool, Default: "false", Category: "agreement",
|
||||
Label: "启用用户协议", Description: "启用后,管理员可管理协议版本,用户登录时需接受协议", IsPublic: true},
|
||||
}
|
||||
|
||||
// registryMap is a lookup index for fast access by key.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// ListTenantPendingAgreements 当前租户用户待接受的协议
|
||||
func (s *sTenant) ListTenantPendingAgreements(ctx context.Context, req *v1.TenantAgreementPendingReq) (*v1.TenantAgreementPendingRes, error) {
|
||||
userID := common.GetCtxUserID(ctx)
|
||||
list, err := common.GetPendingAgreements(ctx, "tenant", userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*v1.TenantPendingAgreementItem, 0, len(list))
|
||||
for _, a := range list {
|
||||
items = append(items, &v1.TenantPendingAgreementItem{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
Content: a.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.TenantAgreementPendingRes{List: items}, nil
|
||||
}
|
||||
|
||||
// AcceptTenantAgreements 租户用户接受协议
|
||||
func (s *sTenant) AcceptTenantAgreements(ctx context.Context, req *v1.TenantAgreementAcceptReq) (*v1.TenantAgreementAcceptRes, error) {
|
||||
userID := common.GetCtxUserID(ctx)
|
||||
r := g.RequestFromCtx(ctx)
|
||||
ipAddress := r.GetClientIp()
|
||||
userAgent := r.GetHeader("User-Agent")
|
||||
|
||||
err := common.AcceptAgreements(ctx, "tenant", userID, req.AgreementIds, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.TenantAgreementAcceptRes{}, nil
|
||||
}
|
||||
|
||||
// ListCurrentAgreements 获取所有当前生效的协议列表(公开)
|
||||
func (s *sTenant) ListCurrentAgreements(ctx context.Context, req *v1.AgreementCurrentListReq) (*v1.AgreementCurrentListRes, error) {
|
||||
list, err := common.GetCurrentAgreements(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*v1.PublicAgreementItem, 0, len(list))
|
||||
for _, a := range list {
|
||||
items = append(items, &v1.PublicAgreementItem{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
ForceAccept: a.ForceAccept,
|
||||
PublishedAt: a.PublishedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.AgreementCurrentListRes{List: items}, nil
|
||||
}
|
||||
|
||||
// GetCurrentAgreementByCode 按标识码获取当前协议详情(公开)
|
||||
func (s *sTenant) GetCurrentAgreementByCode(ctx context.Context, req *v1.AgreementCurrentGetReq) (*v1.AgreementCurrentGetRes, error) {
|
||||
detail, err := common.GetCurrentAgreementByCode(ctx, req.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detail == nil {
|
||||
return nil, common.NewNotFoundError("协议不存在")
|
||||
}
|
||||
|
||||
return &v1.AgreementCurrentGetRes{
|
||||
Id: detail.Id,
|
||||
Code: detail.Code,
|
||||
Title: detail.Title,
|
||||
Version: detail.Version,
|
||||
Content: detail.Content,
|
||||
ForceAccept: detail.ForceAccept,
|
||||
PublishedAt: detail.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -205,6 +205,22 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
res.User.Username = username
|
||||
res.User.Role = "owner"
|
||||
|
||||
// 检查待接受协议
|
||||
if common.Config().GetBool(ctx, "agreement_enabled") {
|
||||
if pending, err := common.GetPendingAgreements(ctx, "tenant", ownerUserID); err == nil && len(pending) > 0 {
|
||||
items := make([]*v1.TenantLoginPendingAgreement, 0, len(pending))
|
||||
for _, a := range pending {
|
||||
items = append(items, &v1.TenantLoginPendingAgreement{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
})
|
||||
}
|
||||
res.PendingAgreements = items
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -435,6 +451,22 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
}
|
||||
}
|
||||
|
||||
// 检查待接受协议
|
||||
if common.Config().GetBool(ctx, "agreement_enabled") {
|
||||
if pending, err := common.GetPendingAgreements(ctx, "tenant", user.Id); err == nil && len(pending) > 0 {
|
||||
items := make([]*v1.TenantLoginPendingAgreement, 0, len(pending))
|
||||
for _, a := range pending {
|
||||
items = append(items, &v1.TenantLoginPendingAgreement{
|
||||
Id: a.Id,
|
||||
Code: a.Code,
|
||||
Title: a.Title,
|
||||
Version: a.Version,
|
||||
})
|
||||
}
|
||||
res.PendingAgreements = items
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package do
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// SysAgreementAcceptances is the golang structure of table sys_agreement_acceptances for DAO operations like Where/Data.
|
||||
type SysAgreementAcceptances struct {
|
||||
g.Meta `orm:"table:sys_agreement_acceptances, do:true"`
|
||||
Id any //
|
||||
AgreementId any // 关联协议版本ID
|
||||
UserType any // 用户类型:admin / tenant
|
||||
UserId any // 用户ID
|
||||
IpAddress any // IP地址
|
||||
UserAgent any // User-Agent
|
||||
CreatedAt *gtime.Time //
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package do
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// SysAgreements is the golang structure of table sys_agreements for DAO operations like Where/Data.
|
||||
type SysAgreements struct {
|
||||
g.Meta `orm:"table:sys_agreements, do:true"`
|
||||
Id any //
|
||||
Code any // 协议标识码
|
||||
Version any // 版本号
|
||||
Title any // 协议标题
|
||||
Content any // 协议正文(Markdown)
|
||||
Summary any // 版本变更摘要
|
||||
Status any // 状态:draft / published / archived
|
||||
IsCurrent any // 是否为当前生效版本
|
||||
ForceAccept any // 是否强制用户接受
|
||||
PublishedAt *gtime.Time // 发布时间
|
||||
CreatedBy any // 创建的管理员ID
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// SysAgreementAcceptances is the golang structure for table sys_agreement_acceptances.
|
||||
type SysAgreementAcceptances struct {
|
||||
Id int64 `json:"id" orm:"id"`
|
||||
AgreementId int64 `json:"agreement_id" orm:"agreement_id"`
|
||||
UserType string `json:"user_type" orm:"user_type"`
|
||||
UserId int64 `json:"user_id" orm:"user_id"`
|
||||
IpAddress string `json:"ip_address" orm:"ip_address"`
|
||||
UserAgent string `json:"user_agent" orm:"user_agent"`
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// SysAgreements is the golang structure for table sys_agreements.
|
||||
type SysAgreements struct {
|
||||
Id int64 `json:"id" orm:"id"`
|
||||
Code string `json:"code" orm:"code"`
|
||||
Version string `json:"version" orm:"version"`
|
||||
Title string `json:"title" orm:"title"`
|
||||
Content string `json:"content" orm:"content"`
|
||||
Summary string `json:"summary" orm:"summary"`
|
||||
Status string `json:"status" orm:"status"`
|
||||
IsCurrent bool `json:"is_current" orm:"is_current"`
|
||||
ForceAccept bool `json:"force_accept" orm:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at" orm:"published_at"`
|
||||
CreatedBy int64 `json:"created_by" orm:"created_by"`
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
|
||||
}
|
||||
@@ -27,6 +27,24 @@ type (
|
||||
ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error)
|
||||
// ExportUsers exports admin users to CSV or Excel.
|
||||
ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*v1.AdminUserExportRes, error)
|
||||
// CreateAgreement 创建协议版本
|
||||
CreateAgreement(ctx context.Context, req *v1.AgreementCreateReq) (*v1.AgreementCreateRes, error)
|
||||
// ListAgreements 协议版本列表
|
||||
ListAgreements(ctx context.Context, req *v1.AgreementListReq) (*v1.AgreementListRes, error)
|
||||
// GetAgreement 协议版本详情
|
||||
GetAgreement(ctx context.Context, req *v1.AgreementGetReq) (*v1.AgreementGetRes, error)
|
||||
// UpdateAgreement 更新协议版本(仅 draft)
|
||||
UpdateAgreement(ctx context.Context, req *v1.AgreementUpdateReq) (*v1.AgreementUpdateRes, error)
|
||||
// DeleteAgreement 删除协议版本(仅 draft)
|
||||
DeleteAgreement(ctx context.Context, req *v1.AgreementDeleteReq) (*v1.AgreementDeleteRes, error)
|
||||
// PublishAgreement 发布协议版本
|
||||
PublishAgreement(ctx context.Context, req *v1.AgreementPublishReq) (*v1.AgreementPublishRes, error)
|
||||
// ListAgreementAcceptances 协议接受记录列表
|
||||
ListAgreementAcceptances(ctx context.Context, req *v1.AgreementAcceptanceListReq) (*v1.AgreementAcceptanceListRes, error)
|
||||
// ListAdminPendingAgreements 当前管理员待接受的协议
|
||||
ListAdminPendingAgreements(ctx context.Context, req *v1.AdminAgreementPendingReq) (*v1.AdminAgreementPendingRes, error)
|
||||
// AcceptAdminAgreements 管理员接受协议
|
||||
AcceptAdminAgreements(ctx context.Context, req *v1.AdminAgreementAcceptReq) (*v1.AdminAgreementAcceptRes, error)
|
||||
// GetAuditConfig retrieves the global audit level from sys_options.
|
||||
GetAuditConfig(ctx context.Context, _ *v1.AuditConfigGetReq) (*v1.AuditConfigGetRes, error)
|
||||
// UpdateAuditConfig updates the global audit level.
|
||||
@@ -225,6 +243,7 @@ type (
|
||||
// FetchOfficialPricing 拉取模型官方定价(来自 LiteLLM + models.dev 双数据源)
|
||||
FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchOfficialReq) (*v1.PricingFetchOfficialRes, error)
|
||||
// FetchOfficialModelInfo 按模型名称拉取官方模型信息(上下文长度+能力特性)
|
||||
// 按优先级尝试 LiteLLM → OpenRouter → BaseLLM,返回第一个匹配的结果
|
||||
FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (*v1.ModelFetchOfficialInfoRes, error)
|
||||
// ExportModels exports model list to CSV or Excel.
|
||||
ExportModels(ctx context.Context, req *v1.ModelExportReq) (*v1.ModelExportRes, error)
|
||||
|
||||
@@ -13,6 +13,14 @@ import (
|
||||
|
||||
type (
|
||||
ITenant interface {
|
||||
// ListTenantPendingAgreements 当前租户用户待接受的协议
|
||||
ListTenantPendingAgreements(ctx context.Context, req *v1.TenantAgreementPendingReq) (*v1.TenantAgreementPendingRes, error)
|
||||
// AcceptTenantAgreements 租户用户接受协议
|
||||
AcceptTenantAgreements(ctx context.Context, req *v1.TenantAgreementAcceptReq) (*v1.TenantAgreementAcceptRes, error)
|
||||
// ListCurrentAgreements 获取所有当前生效的协议列表(公开)
|
||||
ListCurrentAgreements(ctx context.Context, req *v1.AgreementCurrentListReq) (*v1.AgreementCurrentListRes, error)
|
||||
// GetCurrentAgreementByCode 按标识码获取当前协议详情(公开)
|
||||
GetCurrentAgreementByCode(ctx context.Context, req *v1.AgreementCurrentGetReq) (*v1.AgreementCurrentGetRes, error)
|
||||
// ApiKeyList 列出 API Keys,支持按类型过滤
|
||||
ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (*v1.TenantApiKeyListRes, error)
|
||||
// ApiKeyCreate 创建新的 API Key
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- +goose Up
|
||||
|
||||
-- ============================================================
|
||||
-- sys_agreements — 用户协议/政策版本管理
|
||||
-- ============================================================
|
||||
CREATE TABLE sys_agreements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
summary VARCHAR(500) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
is_current BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
force_accept BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_sys_agreements_current
|
||||
ON sys_agreements (code) WHERE is_current = TRUE;
|
||||
|
||||
CREATE UNIQUE INDEX uk_sys_agreements_code_version
|
||||
ON sys_agreements (code, version);
|
||||
|
||||
CREATE INDEX idx_sys_agreements_code_status
|
||||
ON sys_agreements (code, status);
|
||||
|
||||
COMMENT ON TABLE sys_agreements IS '用户协议/政策版本管理';
|
||||
COMMENT ON COLUMN sys_agreements.id IS '主键ID';
|
||||
COMMENT ON COLUMN sys_agreements.code IS '协议标识码:terms(用户协议) / privacy(隐私政策)';
|
||||
COMMENT ON COLUMN sys_agreements.version IS '版本号,如 1.0、2.0';
|
||||
COMMENT ON COLUMN sys_agreements.title IS '协议标题';
|
||||
COMMENT ON COLUMN sys_agreements.content IS '协议正文(Markdown)';
|
||||
COMMENT ON COLUMN sys_agreements.summary IS '版本变更摘要';
|
||||
COMMENT ON COLUMN sys_agreements.status IS '状态:draft(草稿) / published(已发布) / archived(已归档)';
|
||||
COMMENT ON COLUMN sys_agreements.is_current IS '是否为该标识码的当前生效版本(每个code仅一条)';
|
||||
COMMENT ON COLUMN sys_agreements.force_accept IS '是否强制用户接受(true=登录后必须接受才能继续)';
|
||||
COMMENT ON COLUMN sys_agreements.published_at IS '发布时间';
|
||||
COMMENT ON COLUMN sys_agreements.created_by IS '创建的管理员ID';
|
||||
COMMENT ON COLUMN sys_agreements.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN sys_agreements.updated_at IS '更新时间';
|
||||
|
||||
-- ============================================================
|
||||
-- sys_agreement_acceptances — 用户协议接受记录
|
||||
-- ============================================================
|
||||
CREATE TABLE sys_agreement_acceptances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agreement_id BIGINT NOT NULL,
|
||||
user_type VARCHAR(20) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_sys_agreement_acceptances_user
|
||||
ON sys_agreement_acceptances (agreement_id, user_type, user_id);
|
||||
|
||||
CREATE INDEX idx_sys_agreement_acceptances_user_lookup
|
||||
ON sys_agreement_acceptances (user_type, user_id, agreement_id);
|
||||
|
||||
CREATE INDEX idx_sys_agreement_acceptances_created_brin
|
||||
ON sys_agreement_acceptances USING BRIN (created_at);
|
||||
|
||||
COMMENT ON TABLE sys_agreement_acceptances IS '用户协议接受记录';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.id IS '主键ID';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.agreement_id IS '关联协议版本ID';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.user_type IS '用户类型:admin(管理员) / tenant(租户用户)';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.ip_address IS '接受时的IP地址';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.user_agent IS '接受时的浏览器User-Agent';
|
||||
COMMENT ON COLUMN sys_agreement_acceptances.created_at IS '接受时间';
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS sys_agreement_acceptances;
|
||||
DROP TABLE IF EXISTS sys_agreements;
|
||||
@@ -228,6 +228,12 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/operation/ChangelogsPage.vue'),
|
||||
meta: { title: '更新日志' },
|
||||
},
|
||||
{
|
||||
path: 'agreements',
|
||||
name: 'AdminAgreements',
|
||||
component: () => import('@/views/operation/AgreementsPage.vue'),
|
||||
meta: { title: '用户协议' },
|
||||
},
|
||||
{
|
||||
path: 'realtime',
|
||||
name: 'AdminRealtimeMonitor',
|
||||
|
||||
@@ -10,12 +10,20 @@ export interface AdminUser {
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface PendingAgreement {
|
||||
id: number
|
||||
code: string
|
||||
title: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_at: number
|
||||
user: AdminUser
|
||||
permissions: string[]
|
||||
pending_agreements?: PendingAgreement[]
|
||||
}
|
||||
|
||||
const STORE_KEY = 'admin_auth'
|
||||
@@ -26,6 +34,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
const expiresAt = ref<number | null>(null)
|
||||
const user = ref<AdminUser | null>(null)
|
||||
const permissions = ref<string[]>([])
|
||||
const pendingAgreements = ref<PendingAgreement[]>([])
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
@@ -89,11 +98,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
if (data.data?.totp_required) {
|
||||
return data.data
|
||||
}
|
||||
pendingAgreements.value = data.data?.pending_agreements || []
|
||||
applySession(data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
function applyTokensFrom2FA(loginData: any): void {
|
||||
pendingAgreements.value = loginData?.pending_agreements || []
|
||||
applySession(loginData)
|
||||
}
|
||||
|
||||
@@ -133,12 +144,17 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
persist()
|
||||
})
|
||||
|
||||
function clearPendingAgreements(): void {
|
||||
pendingAgreements.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
user,
|
||||
permissions,
|
||||
pendingAgreements,
|
||||
isLoggedIn,
|
||||
isSuperAdmin,
|
||||
login,
|
||||
@@ -146,5 +162,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
logout,
|
||||
refreshTokens,
|
||||
loadFromStorage,
|
||||
clearPendingAgreements,
|
||||
}
|
||||
})
|
||||
@@ -329,6 +329,7 @@ const menuGroups = [
|
||||
{ name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage },
|
||||
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand },
|
||||
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage },
|
||||
{ name: 'AdminAgreements', label: '用户协议', icon: IconFile },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { marked } from 'marked'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { PendingAgreement } from '@/stores/auth'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
import request from '@/utils/request'
|
||||
import SlideCaptcha from '@/components/SlideCaptcha.vue'
|
||||
@@ -31,6 +33,70 @@ const totpLoading = ref(false)
|
||||
const captcha = reactive({ captchaKey: '', captchaX: 0 })
|
||||
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
|
||||
|
||||
// Pending agreements state
|
||||
const showAgreements = ref(false)
|
||||
const agreements = ref<PendingAgreement[]>([])
|
||||
const selectedAgreement = ref<PendingAgreement | null>(null)
|
||||
const agreementContent = ref('')
|
||||
const agreementLoading = ref(false)
|
||||
const acceptLoading = ref(false)
|
||||
const allChecked = ref(false)
|
||||
|
||||
const agreementContentHtml = computed(() => {
|
||||
if (!agreementContent.value) return ''
|
||||
return marked(agreementContent.value) as string
|
||||
})
|
||||
|
||||
function proceedAfterLogin() {
|
||||
const pending = authStore.pendingAgreements
|
||||
if (pending.length > 0) {
|
||||
agreements.value = pending
|
||||
selectedAgreement.value = pending[0]
|
||||
loadAgreementContent(pending[0])
|
||||
showAgreements.value = true
|
||||
return
|
||||
}
|
||||
Message.success('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/admin'
|
||||
router.push(redirect)
|
||||
}
|
||||
|
||||
async function loadAgreementContent(item: PendingAgreement) {
|
||||
selectedAgreement.value = item
|
||||
agreementContent.value = ''
|
||||
agreementLoading.value = true
|
||||
try {
|
||||
const res = await request.get(`/admin/agreements/${item.id}`)
|
||||
agreementContent.value = res.data?.data?.content || ''
|
||||
} catch {
|
||||
agreementContent.value = '加载失败,请重试'
|
||||
} finally {
|
||||
agreementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptAgreements() {
|
||||
if (!allChecked.value) {
|
||||
Message.warning('请先阅读并勾选同意所有协议')
|
||||
return
|
||||
}
|
||||
acceptLoading.value = true
|
||||
try {
|
||||
await request.post('/admin/agreements/accept', {
|
||||
agreement_ids: agreements.value.map(a => a.id),
|
||||
})
|
||||
authStore.clearPendingAgreements()
|
||||
showAgreements.value = false
|
||||
Message.success('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/admin'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
// error auto-displayed
|
||||
} finally {
|
||||
acceptLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
const errors = await formRef.value?.validate()
|
||||
@@ -48,9 +114,7 @@ async function handleLogin() {
|
||||
show2FA.value = true
|
||||
return
|
||||
}
|
||||
Message.success('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/admin'
|
||||
router.push(redirect)
|
||||
proceedAfterLogin()
|
||||
} catch (err: any) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
@@ -73,11 +137,8 @@ async function handle2FAVerify() {
|
||||
provisional_token: provisionalToken.value,
|
||||
code: totpCode.value,
|
||||
})
|
||||
// If we get here, code === 0 (interceptor rejects otherwise)
|
||||
authStore.applyTokensFrom2FA(res.data.data)
|
||||
Message.success('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/admin'
|
||||
router.push(redirect)
|
||||
proceedAfterLogin()
|
||||
} catch {
|
||||
// error toast already shown by interceptor
|
||||
} finally {
|
||||
@@ -232,6 +293,60 @@ async function handle2FAVerify() {
|
||||
<span>© {{ new Date().getFullYear() }} qianfree. Licensed under AGPL-3.0.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Agreements Modal -->
|
||||
<AModal
|
||||
v-model:visible="showAgreements"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:footer="false"
|
||||
:width="680"
|
||||
title-align="start"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>请阅读并同意以下协议</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Agreement tabs -->
|
||||
<div v-if="agreements.length > 1" class="agreement-tabs">
|
||||
<div
|
||||
v-for="item in agreements"
|
||||
:key="item.id"
|
||||
class="agreement-tab"
|
||||
:class="{ 'agreement-tab--active': selectedAgreement?.id === item.id }"
|
||||
@click="loadAgreementContent(item)"
|
||||
>
|
||||
{{ item.title }} ({{ item.version }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agreement content -->
|
||||
<div class="agreement-modal-content">
|
||||
<ASpin :loading="agreementLoading" style="width: 100%">
|
||||
<div class="agreement-body" v-html="agreementContentHtml" />
|
||||
</ASpin>
|
||||
</div>
|
||||
|
||||
<!-- Accept checkbox + button -->
|
||||
<div class="agreement-footer">
|
||||
<ACheckbox v-model="allChecked">
|
||||
我已阅读并同意以上{{ agreements.length > 1 ? '所有' : '' }}协议
|
||||
</ACheckbox>
|
||||
<AButton
|
||||
type="primary"
|
||||
:loading="acceptLoading"
|
||||
:disabled="!allChecked"
|
||||
@click="acceptAgreements"
|
||||
>
|
||||
同意并继续
|
||||
</AButton>
|
||||
</div>
|
||||
</AModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -476,4 +591,79 @@ async function handle2FAVerify() {
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ===== Agreements Modal ===== */
|
||||
.agreement-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.agreement-tab {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.agreement-tab:hover {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.agreement-tab--active {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.agreement-tab--active:hover {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.agreement-modal-content {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #f8fafc;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.agreement-body :deep(h1),
|
||||
.agreement-body :deep(h2),
|
||||
.agreement-body :deep(h3) {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.agreement-body :deep(h1) { font-size: 20px; }
|
||||
.agreement-body :deep(h2) { font-size: 17px; }
|
||||
.agreement-body :deep(h3) { font-size: 15px; }
|
||||
|
||||
.agreement-body :deep(ul),
|
||||
.agreement-body :deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.agreement-body :deep(p) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agreement-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h, computed } from 'vue'
|
||||
import {
|
||||
Tag, Button, Space, Message, Modal,
|
||||
} from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import { marked } from 'marked'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50] })
|
||||
|
||||
const codeFilter = ref<string | undefined>(undefined)
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
|
||||
const codeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '用户协议', value: 'terms' },
|
||||
{ label: '隐私政策', value: 'privacy' },
|
||||
]
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '已发布', value: 'published' },
|
||||
{ label: '已归档', value: 'archived' },
|
||||
]
|
||||
|
||||
const codeLabelMap: Record<string, string> = { terms: '用户协议', privacy: '隐私政策' }
|
||||
const statusColorMap: Record<string, string | undefined> = { draft: undefined, published: 'green', archived: 'gray' }
|
||||
const statusLabelMap: Record<string, string> = { draft: '草稿', published: '已发布', archived: '已归档' }
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '协议类型', dataIndex: 'code', width: 110, render({ record }) {
|
||||
return codeLabelMap[record.code] || record.code
|
||||
}},
|
||||
{ title: '版本', dataIndex: 'version', width: 90 },
|
||||
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true, tooltip: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render({ record }) {
|
||||
const parts = [
|
||||
h(Tag, { color: statusColorMap[record.status], size: 'small' }, () => statusLabelMap[record.status] || record.status),
|
||||
]
|
||||
if (record.is_current) {
|
||||
parts.push(h(Tag, { color: 'arcoblue', size: 'small', style: 'margin-left:4px' }, () => '当前'))
|
||||
}
|
||||
return h(Space, { size: 4 }, () => parts)
|
||||
}},
|
||||
{ title: '强制接受', dataIndex: 'force_accept', width: 90, render({ record }) {
|
||||
return record.force_accept ? '是' : '否'
|
||||
}},
|
||||
{ title: '发布时间', dataIndex: 'published_at', width: 160, render({ record }) { return record.published_at || '-' } },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 260, fixed: 'right',
|
||||
render({ record }) {
|
||||
return h(Space, { size: 'small' }, () => [
|
||||
h(Button, { size: 'small', type: 'text', onClick: () => openEditModal(record) }, () => record.status === 'draft' ? '编辑' : '查看'),
|
||||
record.status === 'draft' ? h(Button, { size: 'small', type: 'text', status: 'success', onClick: () => publishAgreement(record) }, () => '发布') : null,
|
||||
h(Button, { size: 'small', type: 'text', onClick: () => openAcceptances(record) }, () => '接受记录'),
|
||||
record.status === 'draft' ? h(Button, { size: 'small', type: 'text', status: 'danger', onClick: () => deleteAgreement(record) }, () => '删除') : null,
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: pagination.current, page_size: pagination.pageSize }
|
||||
if (codeFilter.value) params.code = codeFilter.value
|
||||
if (statusFilter.value) params.status = statusFilter.value
|
||||
const res = await request.get('/admin/agreements', { params })
|
||||
const d = res.data?.data
|
||||
data.value = d?.list || []
|
||||
pagination.total = d?.total || 0
|
||||
} catch {
|
||||
// error auto-displayed by interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === Create / Edit Drawer ===
|
||||
const showModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingStatus = ref<string>('draft')
|
||||
const formLoading = ref(false)
|
||||
const form = reactive({
|
||||
code: 'terms' as string,
|
||||
version: '',
|
||||
title: '',
|
||||
content: '',
|
||||
summary: '',
|
||||
force_accept: true,
|
||||
})
|
||||
|
||||
const isReadonly = computed(() => editingId.value !== null && editingStatus.value !== 'draft')
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, { code: 'terms', version: '', title: '', content: '', summary: '', force_accept: true })
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingId.value = null
|
||||
editingStatus.value = 'draft'
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function openEditModal(row: any) {
|
||||
editingId.value = row.id
|
||||
editingStatus.value = row.status
|
||||
// Fetch full detail (with content)
|
||||
try {
|
||||
const res = await request.get(`/admin/agreements/${row.id}`)
|
||||
const d = res.data?.data
|
||||
Object.assign(form, {
|
||||
code: d.code || 'terms',
|
||||
version: d.version || '',
|
||||
title: d.title || '',
|
||||
content: d.content || '',
|
||||
summary: d.summary || '',
|
||||
force_accept: d.force_accept ?? true,
|
||||
})
|
||||
} catch {
|
||||
Object.assign(form, {
|
||||
code: row.code || 'terms',
|
||||
version: row.version || '',
|
||||
title: row.title || '',
|
||||
content: '',
|
||||
summary: row.summary || '',
|
||||
force_accept: row.force_accept ?? true,
|
||||
})
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.version.trim()) { Message.warning('请输入版本号'); return }
|
||||
if (!form.title.trim()) { Message.warning('请输入标题'); return }
|
||||
if (!form.content.trim()) { Message.warning('请输入内容'); return }
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await request.put(`/admin/agreements/${editingId.value}`, {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
summary: form.summary,
|
||||
force_accept: form.force_accept,
|
||||
})
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
await request.post('/admin/agreements', form)
|
||||
Message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
// error auto-displayed
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function publishAgreement(row: any) {
|
||||
Modal.confirm({
|
||||
title: '确认发布',
|
||||
content: `确定要发布「${codeLabelMap[row.code] || row.code}」版本 ${row.version} 吗?发布后将替换当前生效版本,所有用户需重新接受。`,
|
||||
okText: '确认发布',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await request.post(`/admin/agreements/${row.id}/publish`)
|
||||
Message.success('发布成功')
|
||||
fetchList()
|
||||
} catch { /* auto-displayed */ }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function deleteAgreement(row: any) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除「${codeLabelMap[row.code] || row.code}」版本 ${row.version} 吗?此操作不可恢复。`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await request.delete(`/admin/agreements/${row.id}`)
|
||||
Message.success('已删除')
|
||||
fetchList()
|
||||
} catch { /* auto-displayed */ }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// === Acceptances Drawer ===
|
||||
const showAcceptances = ref(false)
|
||||
const acceptancesTitle = ref('')
|
||||
const acceptancesLoading = ref(false)
|
||||
const acceptancesData = ref<any[]>([])
|
||||
const acceptancesPagination = reactive({ current: 1, pageSize: 20, total: 0 })
|
||||
const currentAcceptanceId = ref<number | null>(null)
|
||||
|
||||
const acceptanceColumns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户类型', dataIndex: 'user_type', width: 100, render({ record }) {
|
||||
return record.user_type === 'admin' ? '管理员' : '租户用户'
|
||||
}},
|
||||
{ title: '用户ID', dataIndex: 'user_id', width: 90 },
|
||||
{ title: 'IP 地址', dataIndex: 'ip_address', width: 140 },
|
||||
{ title: '接受时间', dataIndex: 'created_at', width: 180 },
|
||||
]
|
||||
|
||||
function openAcceptances(row: any) {
|
||||
currentAcceptanceId.value = row.id
|
||||
acceptancesTitle.value = `${codeLabelMap[row.code] || row.code} ${row.version} - 接受记录`
|
||||
acceptancesPagination.current = 1
|
||||
showAcceptances.value = true
|
||||
fetchAcceptances()
|
||||
}
|
||||
|
||||
async function fetchAcceptances() {
|
||||
if (!currentAcceptanceId.value) return
|
||||
acceptancesLoading.value = true
|
||||
try {
|
||||
const params = { page: acceptancesPagination.current, page_size: acceptancesPagination.pageSize }
|
||||
const res = await request.get(`/admin/agreements/${currentAcceptanceId.value}/acceptances`, { params })
|
||||
const d = res.data?.data
|
||||
acceptancesData.value = d?.list || []
|
||||
acceptancesPagination.total = d?.total || 0
|
||||
} catch {
|
||||
// auto-displayed
|
||||
} finally {
|
||||
acceptancesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === Preview ===
|
||||
const showPreview = ref(false)
|
||||
const previewHtml = computed(() => {
|
||||
if (!form.content) return ''
|
||||
return marked(form.content) as string
|
||||
})
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="用户协议" description="管理用户协议与隐私政策,支持版本管理和发布">
|
||||
<template #actions>
|
||||
<AButton type="primary" @click="openCreateModal">创建协议版本</AButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>协议列表</span>
|
||||
<div class="flex gap-2">
|
||||
<ASelect v-model="codeFilter" :options="codeOptions" style="width: 130px" allow-clear placeholder="协议类型" @change="() => { pagination.current = 1; fetchList() }" />
|
||||
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear placeholder="状态筛选" @change="() => { pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ATable :columns="columns" :data="data" :loading="loading" row-key="id" :scroll="{ x: 1400 }" :pagination="false" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
<!-- Create / Edit Drawer -->
|
||||
<ADrawer v-model:visible="showModal" :title="editingId ? (isReadonly ? '查看协议' : '编辑协议') : '创建协议版本'" :width="720" :mask-closable="false" :footer="!isReadonly" unmount-on-close>
|
||||
<AForm :model="form" :auto-label-width="true" layout="vertical">
|
||||
<div class="flex gap-4">
|
||||
<AFormItem label="协议类型" required class="flex-1">
|
||||
<ASelect v-model="form.code" :disabled="!!editingId">
|
||||
<AOption value="terms">用户协议</AOption>
|
||||
<AOption value="privacy">隐私政策</AOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem label="版本号" required class="flex-1">
|
||||
<AInput v-model="form.version" placeholder="例如: 1.0" :disabled="!!editingId" />
|
||||
</AFormItem>
|
||||
</div>
|
||||
<AFormItem label="标题" required>
|
||||
<AInput v-model="form.title" placeholder="请输入协议标题" :disabled="isReadonly" />
|
||||
</AFormItem>
|
||||
<AFormItem label="变更摘要">
|
||||
<AInput v-model="form.summary" placeholder="本版本的主要变更说明(可选)" :disabled="isReadonly" />
|
||||
</AFormItem>
|
||||
<AFormItem label="强制接受">
|
||||
<ASwitch v-model="form.force_accept" :disabled="isReadonly" />
|
||||
<span class="ml-2 text-gray-500 text-sm">开启后用户必须接受此协议才能继续使用</span>
|
||||
</AFormItem>
|
||||
<AFormItem label="协议正文" required>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<AButton size="mini" type="text" @click="showPreview = !showPreview">
|
||||
{{ showPreview ? '编辑' : '预览' }}
|
||||
</AButton>
|
||||
</div>
|
||||
<ATextarea v-if="!showPreview" v-model="form.content" :auto-size="{ minRows: 12, maxRows: 30 }" placeholder="Markdown 格式的协议正文" :disabled="isReadonly" />
|
||||
<div v-else class="agreement-preview" v-html="previewHtml" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<template v-if="!isReadonly" #footer>
|
||||
<Space>
|
||||
<AButton @click="showModal = false">取消</AButton>
|
||||
<AButton type="primary" :loading="formLoading" @click="handleSubmit">确认</AButton>
|
||||
</Space>
|
||||
</template>
|
||||
</ADrawer>
|
||||
|
||||
<!-- Acceptances Drawer -->
|
||||
<ADrawer v-model:visible="showAcceptances" :title="acceptancesTitle" :width="640" unmount-on-close>
|
||||
<ATable :columns="acceptanceColumns" :data="acceptancesData" :loading="acceptancesLoading" row-key="id" :pagination="false" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<APagination v-model:current="acceptancesPagination.current" :total="acceptancesPagination.total" :page-size="acceptancesPagination.pageSize" @change="fetchAcceptances" />
|
||||
</div>
|
||||
</ADrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agreement-preview {
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-fill-1);
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agreement-preview :deep(h1),
|
||||
.agreement-preview :deep(h2),
|
||||
.agreement-preview :deep(h3) {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agreement-preview :deep(ul),
|
||||
.agreement-preview :deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.agreement-preview :deep(p) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import type { PendingAgreement } from '@/stores/tenant-auth'
|
||||
import request from '@/utils/request'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
agreements: PendingAgreement[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accepted: []
|
||||
}>()
|
||||
|
||||
const selectedAgreement = ref<PendingAgreement | null>(null)
|
||||
const agreementContent = ref('')
|
||||
const contentLoading = ref(false)
|
||||
const acceptLoading = ref(false)
|
||||
const allChecked = ref(false)
|
||||
|
||||
const contentHtml = computed(() => {
|
||||
if (!agreementContent.value) return ''
|
||||
return marked(agreementContent.value) as string
|
||||
})
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.agreements.length > 0) {
|
||||
selectedAgreement.value = props.agreements[0]
|
||||
loadContent(props.agreements[0])
|
||||
allChecked.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function loadContent(item: PendingAgreement) {
|
||||
selectedAgreement.value = item
|
||||
agreementContent.value = ''
|
||||
contentLoading.value = true
|
||||
try {
|
||||
const res = await request.get(`/tenant/agreements/current/${item.code}`)
|
||||
agreementContent.value = res.data?.data?.content || ''
|
||||
} catch {
|
||||
agreementContent.value = '加载失败,请重试'
|
||||
} finally {
|
||||
contentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
if (!allChecked.value) return
|
||||
acceptLoading.value = true
|
||||
try {
|
||||
await request.post('/tenant/agreements/accept', {
|
||||
agreement_ids: props.agreements.map(a => a.id),
|
||||
})
|
||||
emit('accepted')
|
||||
} catch {
|
||||
// error auto-displayed by interceptor
|
||||
} finally {
|
||||
acceptLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="modal-overlay">
|
||||
<div class="modal-content" style="max-width: 640px">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Icon name="document" size="md" class="text-amber-600" />
|
||||
</div>
|
||||
<h3 class="modal-title">请阅读并同意以下协议</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<!-- Tabs -->
|
||||
<div v-if="agreements.length > 1" class="flex gap-1 px-4 pt-4 sm:px-6 sm:pt-4">
|
||||
<button
|
||||
v-for="item in agreements"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all"
|
||||
:class="selectedAgreement?.id === item.id
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'"
|
||||
@click="loadContent(item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="agreement-scroll rounded-xl border border-gray-200 bg-gray-50 p-5">
|
||||
<div v-if="contentLoading" class="flex items-center justify-center py-12">
|
||||
<div class="spinner h-5 w-5 border-primary-500"></div>
|
||||
</div>
|
||||
<div v-else class="markdown-content" v-html="contentHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none flex-1">
|
||||
<input
|
||||
v-model="allChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/30 transition-colors cursor-pointer"
|
||||
/>
|
||||
<span class="text-sm text-gray-600">
|
||||
我已阅读并同意以上{{ agreements.length > 1 ? '所有' : '' }}协议
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!allChecked || acceptLoading"
|
||||
@click="handleAccept"
|
||||
>
|
||||
<div v-if="acceptLoading" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ acceptLoading ? '提交中...' : '同意并继续' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agreement-scroll {
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.agreement-scroll :deep(h1),
|
||||
.agreement-scroll :deep(h2),
|
||||
.agreement-scroll :deep(h3) {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agreement-scroll :deep(h1) { font-size: 20px; }
|
||||
.agreement-scroll :deep(h2) { font-size: 17px; }
|
||||
.agreement-scroll :deep(h3) { font-size: 15px; }
|
||||
|
||||
.agreement-scroll :deep(ul),
|
||||
.agreement-scroll :deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.agreement-scroll :deep(p) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,12 @@ const tenantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/ForgotPasswordPage.vue'),
|
||||
meta: { requiresAuth: false, title: '找回密码' },
|
||||
},
|
||||
{
|
||||
path: '/tenant/agreement/:code',
|
||||
name: 'TenantAgreement',
|
||||
component: () => import('@/views/auth/AgreementPage.vue'),
|
||||
meta: { requiresAuth: false, title: '协议详情' },
|
||||
},
|
||||
{
|
||||
path: '/tenant/join',
|
||||
name: 'TenantJoin',
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface TenantUser {
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface PendingAgreement {
|
||||
id: number
|
||||
code: string
|
||||
title: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
@@ -22,6 +29,7 @@ interface AuthResponse {
|
||||
tenant: TenantInfo
|
||||
user: TenantUser
|
||||
permissions?: string[]
|
||||
pending_agreements?: PendingAgreement[]
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
@@ -44,6 +52,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
const tenant = ref<TenantInfo | null>(null)
|
||||
const user = ref<TenantUser | null>(null)
|
||||
const permissions = ref<string[]>([])
|
||||
const pendingAgreements = ref<PendingAgreement[]>([])
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
@@ -116,17 +125,21 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
if (data.data?.totp_required) {
|
||||
return data.data
|
||||
}
|
||||
pendingAgreements.value = data.data?.pending_agreements || []
|
||||
applySession(data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
function applyTokensFrom2FA(loginData: any): void {
|
||||
pendingAgreements.value = loginData?.pending_agreements || []
|
||||
applySession(loginData)
|
||||
}
|
||||
|
||||
async function register(payload: RegisterPayload): Promise<void> {
|
||||
async function register(payload: RegisterPayload): Promise<any> {
|
||||
const { data } = await request.post<{ data: AuthResponse }>('/tenant/auth/register', payload)
|
||||
pendingAgreements.value = data.data?.pending_agreements || []
|
||||
applySession(data.data)
|
||||
return data.data
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
@@ -166,6 +179,10 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
persist()
|
||||
})
|
||||
|
||||
function clearPendingAgreements(): void {
|
||||
pendingAgreements.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
@@ -173,6 +190,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
tenant,
|
||||
user,
|
||||
permissions,
|
||||
pendingAgreements,
|
||||
isLoggedIn,
|
||||
isOwner,
|
||||
login,
|
||||
@@ -181,5 +199,6 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
logout,
|
||||
refreshTokens,
|
||||
loadFromStorage,
|
||||
clearPendingAgreements,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
import request from '@/utils/request'
|
||||
import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const code = computed(() => route.params.code as string)
|
||||
|
||||
const title = ref('')
|
||||
const version = ref('')
|
||||
const content = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const contentHtml = computed(() => {
|
||||
if (!content.value) return ''
|
||||
return marked(content.value) as string
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await request.get(`/tenant/agreements/current/${code.value}`)
|
||||
const data = res.data?.data
|
||||
if (data) {
|
||||
title.value = data.title
|
||||
version.value = data.version
|
||||
content.value = data.content || ''
|
||||
} else {
|
||||
error.value = '未找到该协议'
|
||||
}
|
||||
} catch {
|
||||
error.value = '加载协议内容失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="animate-slide-up">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="spinner h-6 w-6 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gray-100">
|
||||
<Icon name="document" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">{{ title }}</h2>
|
||||
<p v-if="version" class="mt-1 text-xs text-gray-400">版本 {{ version }}</p>
|
||||
</div>
|
||||
<div class="agreement-content rounded-xl border border-gray-200 bg-gray-50 p-5" v-html="contentHtml" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<p class="text-gray-500">
|
||||
<router-link to="/tenant/login" class="text-primary-600 font-medium hover:text-primary-700 transition-colors">
|
||||
返回登录
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agreement-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.agreement-content :deep(h1),
|
||||
.agreement-content :deep(h2),
|
||||
.agreement-content :deep(h3) {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agreement-content :deep(h1) { font-size: 20px; }
|
||||
.agreement-content :deep(h2) { font-size: 17px; }
|
||||
.agreement-content :deep(h3) { font-size: 15px; }
|
||||
|
||||
.agreement-content :deep(ul),
|
||||
.agreement-content :deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.agreement-content :deep(p) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import Turnstile from '@/components/common/Turnstile.vue'
|
||||
import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -42,6 +43,26 @@ const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
|
||||
const emailInput = ref<HTMLInputElement | null>(null)
|
||||
const accountInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Pending agreements
|
||||
const showAgreements = ref(false)
|
||||
|
||||
function proceedAfterLogin() {
|
||||
const pending = authStore.pendingAgreements
|
||||
if (pending.length > 0) {
|
||||
showAgreements.value = true
|
||||
return
|
||||
}
|
||||
const redirect = (route.query.redirect as string) || '/tenant/dashboard'
|
||||
router.push(redirect)
|
||||
}
|
||||
|
||||
function onAgreementsAccepted() {
|
||||
authStore.clearPendingAgreements()
|
||||
showAgreements.value = false
|
||||
const redirect = (route.query.redirect as string) || '/tenant/dashboard'
|
||||
router.push(redirect)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
nextTick(() => emailInput.value?.focus())
|
||||
await fetchSettings()
|
||||
@@ -108,8 +129,7 @@ async function handleAdminLogin() {
|
||||
show2FA.value = true
|
||||
return
|
||||
}
|
||||
const redirect = (route.query.redirect as string) || '/tenant/dashboard'
|
||||
await router.push(redirect)
|
||||
proceedAfterLogin()
|
||||
} catch (err) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
@@ -140,8 +160,7 @@ async function handleRamLogin() {
|
||||
show2FA.value = true
|
||||
return
|
||||
}
|
||||
const redirect = (route.query.redirect as string) || '/tenant/dashboard'
|
||||
await router.push(redirect)
|
||||
proceedAfterLogin()
|
||||
} catch (err) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
@@ -164,8 +183,7 @@ async function handle2FAVerify() {
|
||||
code: totpCode.value,
|
||||
}, { _suppressErrorMsg: true } as any)
|
||||
authStore.applyTokensFrom2FA(res.data.data)
|
||||
const redirect = (route.query.redirect as string) || '/tenant/dashboard'
|
||||
await router.push(redirect)
|
||||
proceedAfterLogin()
|
||||
} catch (e: any) {
|
||||
const apiErr = extractApiError(e)
|
||||
errorMsg.value = apiErr?.message || '验证失败'
|
||||
@@ -476,8 +494,19 @@ async function handleOAuthLogin(provider: string) {
|
||||
立即创建
|
||||
</router-link>
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
<router-link to="/tenant/agreement/terms" target="_blank" class="hover:text-gray-500 transition-colors">服务条款</router-link>
|
||||
<span class="mx-1">·</span>
|
||||
<router-link to="/tenant/agreement/privacy" target="_blank" class="hover:text-gray-500 transition-colors">隐私政策</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
|
||||
<AgreementAcceptModal
|
||||
:show="showAgreements"
|
||||
:agreements="authStore.pendingAgreements"
|
||||
@accepted="onAgreementsAccepted"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,6 +7,7 @@ import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import Turnstile from '@/components/common/Turnstile.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
|
||||
@@ -35,6 +36,24 @@ const userForm = reactive({
|
||||
const orgErrors = reactive<Record<string, string>>({})
|
||||
const userErrors = reactive<Record<string, string>>({})
|
||||
|
||||
// Pending agreements
|
||||
const showAgreements = ref(false)
|
||||
|
||||
function proceedAfterRegister() {
|
||||
const pending = authStore.pendingAgreements
|
||||
if (pending.length > 0) {
|
||||
showAgreements.value = true
|
||||
return
|
||||
}
|
||||
router.push('/tenant/dashboard')
|
||||
}
|
||||
|
||||
function onAgreementsAccepted() {
|
||||
authStore.clearPendingAgreements()
|
||||
showAgreements.value = false
|
||||
router.push('/tenant/dashboard')
|
||||
}
|
||||
|
||||
const stepTitles = ['组织信息', '管理员信息']
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -179,7 +198,7 @@ async function handleRegister() {
|
||||
}
|
||||
|
||||
await authStore.register(payload)
|
||||
router.push('/tenant/dashboard')
|
||||
proceedAfterRegister()
|
||||
} catch (err: any) {
|
||||
const apiErr = extractApiError(err)
|
||||
const msg = apiErr?.message || '注册失败'
|
||||
@@ -400,6 +419,14 @@ async function handleRegister() {
|
||||
<p v-if="userErrors.confirmPassword" class="input-error-text">{{ userErrors.confirmPassword }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Agreement Notice -->
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
注册即表示您同意我们的
|
||||
<router-link to="/tenant/agreement/terms" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">服务条款</router-link>
|
||||
和
|
||||
<router-link to="/tenant/agreement/privacy" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">隐私政策</router-link>
|
||||
</p>
|
||||
|
||||
<!-- Submit -->
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary btn-lg w-full">
|
||||
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
|
||||
@@ -417,4 +444,10 @@ async function handleRegister() {
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
|
||||
<AgreementAcceptModal
|
||||
:show="showAgreements"
|
||||
:agreements="authStore.pendingAgreements"
|
||||
@accepted="onAgreementsAccepted"
|
||||
/>
|
||||
</template>
|
||||
Reference in new issue
Block a user