更新日报相关功能
This commit is contained in:
1 parent
51fd90fc51
commit
68cdaf38fa
23 files changed
+2313
-1056
No files matched your search
@@ -144,7 +144,7 @@ func (c *AppErpController) Dashboard() {
|
||||
if c.erpDenied(err) {
|
||||
return
|
||||
}
|
||||
c.erpOk(erpDashboardCore(c.actor(cl)))
|
||||
c.erpOk(erpDashboardCore(c.actor(cl), c.erpQuery("period")))
|
||||
}
|
||||
|
||||
// Meta GET /api/erp/meta 单据类型、状态、收付款方式等下拉字典
|
||||
|
||||
@@ -72,6 +72,23 @@ func (c *AuthLoginController) LoginPage() {
|
||||
// 自助账户流程提示:registered=注册成功 / registered_warn=注册成功但加入企业失败 / reset=密码已重置
|
||||
c.Data["Notice"] = c.GetString("notice")
|
||||
c.Data["WarnMsg"] = c.GetString("warn")
|
||||
|
||||
// 登录页品牌:platform「平台设置 → 登录页设置」里的配置,未配置时用默认文案。
|
||||
// 读取失败不阻断登录页,GetPlatformSiteTagMap 会返回空值。
|
||||
brand := models.GetPlatformSiteTagMap()
|
||||
loginTitle := strings.TrimSpace(brand["login_title"])
|
||||
if loginTitle == "" {
|
||||
loginTitle = "一次登录,全平台通行"
|
||||
}
|
||||
loginSubtitle := strings.TrimSpace(brand["login_subtitle"])
|
||||
if loginSubtitle == "" {
|
||||
loginSubtitle = "基于 OIDC / OAuth 2.0 的统一身份底座,为所有接入应用提供安全、一致的登录体验。"
|
||||
}
|
||||
c.Data["LoginTitle"] = loginTitle
|
||||
c.Data["LoginSubtitle"] = loginSubtitle
|
||||
c.Data["LoginLogo"] = strings.TrimSpace(brand["login_logo"])
|
||||
c.Data["LoginBg"] = strings.TrimSpace(brand["login_bg"])
|
||||
|
||||
c.TplName = "auth/login.tpl"
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ func (c *BackendErpReportController) Dashboard() {
|
||||
if c.denied(err) {
|
||||
return
|
||||
}
|
||||
c.ok(erpDashboardCore(erpNewActor(cl)))
|
||||
c.ok(erpDashboardCore(erpNewActor(cl), c.GetString("period")))
|
||||
}
|
||||
|
||||
// ---- 汇总报表共用:聚合实现见 erp_report_core.go ----
|
||||
|
||||
@@ -187,6 +187,19 @@ func remapReceiptAmounts(oldIDs []uint64, oldAmounts []string, newIDs []uint64)
|
||||
return joinReceiptAmounts(parts)
|
||||
}
|
||||
|
||||
// normalizeReceiptURLs 规范化逗号分隔的票据地址(历史七牛 HTTP 域名改写为存储配置入口),
|
||||
// 保证 HTTPS 页面不会因 Mixed Content 拦截发票预览。
|
||||
func normalizeReceiptURLs(scheme, host, raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = services.ResolvePublicURL(scheme, host, strings.TrimSpace(part))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
|
||||
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
|
||||
if len(ids) == 0 {
|
||||
@@ -282,7 +295,7 @@ func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimburse
|
||||
|
||||
func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
|
||||
receiptFiles := resolveReceiptFiles(scheme, host, item)
|
||||
receiptURLs := strings.TrimSpace(item.ReceiptURLs)
|
||||
receiptURLs := normalizeReceiptURLs(scheme, host, item.ReceiptURLs)
|
||||
receiptFlag := item.ReceiptFlag
|
||||
if len(receiptFiles) > 0 {
|
||||
receiptFlag = 1
|
||||
|
||||
@@ -68,33 +68,84 @@ func erpDocAmount(tid string, docTypes []string, from, to time.Time) (float64, i
|
||||
return round2f(sum), int64(len(docs))
|
||||
}
|
||||
|
||||
// erpDashboardTrend 近 6 个月采购/销售趋势
|
||||
// erpDashboardTrend 看板趋势点。Month 保留旧字段(兼容旧页面),Label 为图表 x 轴文案
|
||||
type erpDashboardTrend struct {
|
||||
Month string `json:"month"`
|
||||
Label string `json:"label"`
|
||||
Buy float64 `json:"buy"`
|
||||
Sale float64 `json:"sale"`
|
||||
}
|
||||
|
||||
// erpDashboardCore 经营看板:本月购销/毛利、今日销售、库存价值、往来余额、待办、趋势、预警
|
||||
func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
tid := a.Tid
|
||||
monthFrom, monthTo := erpMonthRange(0)
|
||||
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
|
||||
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
|
||||
now := time.Now()
|
||||
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
// erpDashboardPeriod 解析看板统计周期:week|month|quarter|year,缺省或非法值回落 month
|
||||
func erpDashboardPeriod(raw string) string {
|
||||
switch p := strings.ToLower(strings.TrimSpace(raw)); p {
|
||||
case "week", "quarter", "year":
|
||||
return p
|
||||
default:
|
||||
return "month"
|
||||
}
|
||||
}
|
||||
|
||||
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, monthFrom, monthTo)
|
||||
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, monthFrom, monthTo)
|
||||
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, monthFrom, monthTo)
|
||||
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
|
||||
// erpPeriodSpan 趋势分桶:Key 用于把单据日期映射到桶,Label 是图表 x 轴文案
|
||||
type erpPeriodSpan struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
// 本月毛利(出库流水成本口径)
|
||||
// erpPeriodRange 返回周期区间 [from, to)、中文标签与趋势分桶。
|
||||
// 周/月按天分桶,季度按 3 个月分桶,年度按 12 个月分桶。
|
||||
func erpPeriodRange(period string, now time.Time) (time.Time, time.Time, string, []erpPeriodSpan) {
|
||||
switch period {
|
||||
case "week":
|
||||
// 周一为一周起点;Go 的 Sunday=0,需要换算成「距本周一的天数」
|
||||
offset := (int(now.Weekday()) + 6) % 7
|
||||
from := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -offset)
|
||||
names := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
|
||||
spans := make([]erpPeriodSpan, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := from.AddDate(0, 0, i)
|
||||
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: names[i]})
|
||||
}
|
||||
return from, from.AddDate(0, 0, 7), "本周", spans
|
||||
case "quarter":
|
||||
q := (int(now.Month()) - 1) / 3
|
||||
from := time.Date(now.Year(), time.Month(q*3+1), 1, 0, 0, 0, 0, time.Local)
|
||||
spans := make([]erpPeriodSpan, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
m := from.AddDate(0, i, 0)
|
||||
spans = append(spans, erpPeriodSpan{Key: m.Format("2006-01"), Label: fmt.Sprintf("%d月", int(m.Month()))})
|
||||
}
|
||||
return from, from.AddDate(0, 3, 0), "本季度", spans
|
||||
case "year":
|
||||
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
|
||||
spans := make([]erpPeriodSpan, 0, 12)
|
||||
for i := 1; i <= 12; i++ {
|
||||
spans = append(spans, erpPeriodSpan{Key: fmt.Sprintf("%04d-%02d", now.Year(), i), Label: fmt.Sprintf("%d月", i)})
|
||||
}
|
||||
return from, from.AddDate(1, 0, 0), "本年", spans
|
||||
default: // month
|
||||
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
to := from.AddDate(0, 1, 0)
|
||||
spans := make([]erpPeriodSpan, 0, 31)
|
||||
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
|
||||
spans = append(spans, erpPeriodSpan{Key: d.Format("2006-01-02"), Label: fmt.Sprintf("%d日", d.Day())})
|
||||
}
|
||||
return from, to, "本月", spans
|
||||
}
|
||||
}
|
||||
|
||||
// erpDashboardStats 区间内购销/毛利统计(只认已生效单据与库存流水)
|
||||
func erpDashboardStats(tid string, from, to time.Time) map[string]interface{} {
|
||||
buyAmt, buyCnt := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, from, to)
|
||||
saleAmt, saleCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, from, to)
|
||||
backAmt, _ := erpDocAmount(tid, []string{models.ErpDocSalesReturn}, from, to)
|
||||
|
||||
// 毛利按出库流水成本口径
|
||||
var ledgers []models.ErpStockLedger
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpStockLedger)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("doc_type__in", []string{models.ErpDocSalesOut, models.ErpDocSalesReturn}).
|
||||
Filter("create_time__gte", monthFrom).Filter("create_time__lt", monthTo).
|
||||
Filter("create_time__gte", from).Filter("create_time__lt", to).
|
||||
All(&ledgers, "direction", "total_cost")
|
||||
var cost float64
|
||||
for _, l := range ledgers {
|
||||
@@ -105,6 +156,82 @@ func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
profit := saleAmt - backAmt - cost
|
||||
rate := 0.0
|
||||
if saleAmt-backAmt > 0.005 {
|
||||
rate = round2f(profit / (saleAmt - backAmt) * 100)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"purchase_amount": buyAmt, "purchase_count": buyCnt,
|
||||
"sales_amount": saleAmt, "sales_count": saleCnt,
|
||||
"return_amount": backAmt, "cost": round2f(cost),
|
||||
"profit": round2f(profit), "profit_rate": rate,
|
||||
}
|
||||
}
|
||||
|
||||
// erpDashboardPeriodTrend 当前周期内的购销分布:一次查出单据后按分桶汇总,
|
||||
// 避免「每个点都打一次库」(年度 12 点、月度 31 点)。
|
||||
func erpDashboardPeriodTrend(tid string, from, to time.Time, period string, spans []erpPeriodSpan) []erpDashboardTrend {
|
||||
var docs []models.ErpDocument
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpDocument)).
|
||||
Filter("tenant_id", tid).
|
||||
Filter("doc_type__in", []string{models.ErpDocPurchaseIn, models.ErpDocSalesOut}).
|
||||
Filter("status", models.ErpDocStatusApproved).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("biz_date__gte", from).Filter("biz_date__lt", to).
|
||||
All(&docs, "doc_type", "biz_date", "actual_amount")
|
||||
|
||||
index := make(map[string]int, len(spans))
|
||||
for i, s := range spans {
|
||||
index[s.Key] = i
|
||||
}
|
||||
buy := make([]float64, len(spans))
|
||||
sale := make([]float64, len(spans))
|
||||
for _, d := range docs {
|
||||
key := d.BizDate.Format("2006-01-02")
|
||||
if period == "quarter" || period == "year" {
|
||||
key = d.BizDate.Format("2006-01")
|
||||
}
|
||||
i, ok := index[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if d.DocType == models.ErpDocPurchaseIn {
|
||||
buy[i] += d.ActualAmount
|
||||
} else {
|
||||
sale[i] += d.ActualAmount
|
||||
}
|
||||
}
|
||||
list := make([]erpDashboardTrend, 0, len(spans))
|
||||
for i, s := range spans {
|
||||
list = append(list, erpDashboardTrend{
|
||||
Month: s.Key, Label: s.Label,
|
||||
Buy: round2f(buy[i]), Sale: round2f(sale[i]),
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// erpDashboardCore 经营看板:所选周期的购销/毛利、今日销售、库存价值、往来余额、
|
||||
// 待办、近 6 个月趋势、当前周期趋势与预警。period 取 week|month|quarter|year,缺省 month。
|
||||
// month 字段保持「本月」口径不变,供旧页面与小程序继续使用;current 为所选周期。
|
||||
func erpDashboardCore(a erpActor, periodRaw string) map[string]interface{} {
|
||||
tid := a.Tid
|
||||
period := erpDashboardPeriod(periodRaw)
|
||||
now := time.Now()
|
||||
|
||||
monthFrom, monthTo := erpMonthRange(0)
|
||||
monthStats := erpDashboardStats(tid, monthFrom, monthTo)
|
||||
|
||||
periodFrom, periodTo, periodLabel, spans := erpPeriodRange(period, now)
|
||||
current := monthStats
|
||||
if period != "month" {
|
||||
current = erpDashboardStats(tid, periodFrom, periodTo)
|
||||
}
|
||||
|
||||
// 不能用 time.Now().Truncate(24*time.Hour):它按「自零时起算的绝对时长」取整,
|
||||
// 在东八区得到的是 UTC 零点(本地 08:00),会把当天 00:00–08:00 的单据漏掉。
|
||||
todayFrom := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
todaySale, todayCnt := erpDocAmount(tid, []string{models.ErpDocSalesOut}, todayFrom, todayFrom.AddDate(0, 0, 1))
|
||||
|
||||
var stocks []models.ErpStock
|
||||
_, _ = models.Orm.QueryTable(new(models.ErpStock)).Filter("tenant_id", tid).
|
||||
@@ -142,30 +269,29 @@ func erpDashboardCore(a erpActor) map[string]interface{} {
|
||||
f, t := erpMonthRange(-i)
|
||||
b, _ := erpDocAmount(tid, []string{models.ErpDocPurchaseIn}, f, t)
|
||||
s, _ := erpDocAmount(tid, []string{models.ErpDocSalesOut}, f, t)
|
||||
trendList = append(trendList, erpDashboardTrend{Month: f.Format("2006-01"), Buy: b, Sale: s})
|
||||
month := f.Format("2006-01")
|
||||
trendList = append(trendList, erpDashboardTrend{Month: month, Label: month, Buy: b, Sale: s})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"month": map[string]interface{}{
|
||||
"purchase_amount": buyAmt, "purchase_count": buyCnt,
|
||||
"sales_amount": saleAmt, "sales_count": saleCnt,
|
||||
"return_amount": backAmt, "cost": round2f(cost),
|
||||
"profit": round2f(profit),
|
||||
"profit_rate": func() float64 {
|
||||
if saleAmt-backAmt > 0.005 {
|
||||
return round2f(profit / (saleAmt - backAmt) * 100)
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
"period": period,
|
||||
"range": map[string]interface{}{
|
||||
"from": periodFrom.Format("2006-01-02"),
|
||||
"to": periodTo.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
"label": periodLabel,
|
||||
},
|
||||
"current": current,
|
||||
// 兼容旧调用方:month 始终是本月口径
|
||||
"month": monthStats,
|
||||
"today": map[string]interface{}{"sales_amount": todaySale, "sales_count": todayCnt},
|
||||
"stock": map[string]interface{}{"value": round2f(stockValue), "qty": round4f(stockQty)},
|
||||
"partner_bill": map[string]interface{}{
|
||||
"receivable": round2f(recv), "payable": round2f(pay), "net": round2f(recv - pay),
|
||||
},
|
||||
"todo": map[string]interface{}{"approval_pending": pendingTasks, "draft_docs": draftCnt},
|
||||
"trend": trendList,
|
||||
"warning": erpWarningCounts(tid),
|
||||
"todo": map[string]interface{}{"approval_pending": pendingTasks, "draft_docs": draftCnt},
|
||||
"trend": trendList,
|
||||
"period_trend": erpDashboardPeriodTrend(tid, periodFrom, periodTo, period, spans),
|
||||
"warning": erpWarningCounts(tid),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/pkg/identity"
|
||||
"server/services"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -108,3 +109,110 @@ func (c *PlatformWebsiteSettingController) Save() {
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// loginBrandTags 登录页品牌配置项,存放于 yz_platform_site_setting,
|
||||
// 统一认证中心 /auth/login 渲染时读取。
|
||||
var loginBrandTags = []string{"login_title", "login_subtitle", "login_logo", "login_bg"}
|
||||
|
||||
// LoginBrandInfo GET /platform/loginBrand
|
||||
// 返回登录页品牌配置(主标题 / 副标题 / Logo / 背景图)。
|
||||
func (c *PlatformWebsiteSettingController) LoginBrandInfo() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
code := identity.Status(err)
|
||||
c.jsonErr(code, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
all := models.GetPlatformSiteTagMap()
|
||||
data := make(map[string]string, len(loginBrandTags))
|
||||
for _, tag := range loginBrandTags {
|
||||
data[tag] = all[tag]
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// SaveLoginBrand POST /platform/saveLoginBrand
|
||||
// 入参 { login_title, login_subtitle, login_logo, login_bg }。
|
||||
// 只接受这四个标签,避免与「站点设置」共用的 yz_platform_site_setting 被写入无关项。
|
||||
func (c *PlatformWebsiteSettingController) SaveLoginBrand() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
code := identity.Status(err)
|
||||
c.jsonErr(code, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var payload map[string]string
|
||||
if uerr := json.Unmarshal(raw, &payload); uerr != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
values := make(map[string]string, len(loginBrandTags))
|
||||
for _, tag := range loginBrandTags {
|
||||
if v, ok := payload[tag]; ok {
|
||||
values[tag] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len([]rune(values["login_title"])) > 60 {
|
||||
c.jsonErr(400, 400, "主标题不能超过 60 个字符")
|
||||
return
|
||||
}
|
||||
if len([]rune(values["login_subtitle"])) > 120 {
|
||||
c.jsonErr(400, 400, "副标题不能超过 120 个字符")
|
||||
return
|
||||
}
|
||||
for _, tag := range []string{"login_logo", "login_bg"} {
|
||||
img, ok := normalizeLoginBrandImage(values[tag])
|
||||
if !ok {
|
||||
c.jsonErr(400, 400, "图片地址必须是 HTTPS 或站内相对路径")
|
||||
return
|
||||
}
|
||||
values[tag] = img
|
||||
}
|
||||
|
||||
if err = models.SavePlatformSiteSettings(values); err != nil {
|
||||
c.jsonErr(500, 500, "保存失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// normalizeLoginBrandImage 校验登录页图片地址:
|
||||
// - 站内相对路径(/uploads/...)直接放行;
|
||||
// - HTTPS 完整地址放行;
|
||||
// - HTTP 历史七牛域名尝试改写为存储配置的 HTTPS 基址,改不动则拒绝,
|
||||
// 否则登录页会被浏览器按 Mixed Content 拦截。
|
||||
func normalizeLoginBrandImage(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return raw, true
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "https://") {
|
||||
return raw, true
|
||||
}
|
||||
if strings.HasPrefix(lower, "http://") {
|
||||
rewritten := services.ResolvePublicURL("", "", raw)
|
||||
if strings.HasPrefix(strings.ToLower(rewritten), "https://") {
|
||||
return rewritten, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -65,6 +65,12 @@ var PlatformSiteSettingDefs = []PlatformSiteSettingDef{
|
||||
// 政策条款
|
||||
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"},
|
||||
{Tag: "terms", Title: "使用条款", Remark: "使用条款链接,如 /page/terms | group:政策条款"},
|
||||
|
||||
// 登录页品牌(统一认证中心 /auth/login 左侧展示)
|
||||
{Tag: "login_title", Title: "登录页主标题", Remark: "登录页左侧主标题,留空则使用默认文案 | group:登录页设置"},
|
||||
{Tag: "login_subtitle", Title: "登录页副标题", Remark: "登录页左侧副标题,留空则使用默认文案 | group:登录页设置"},
|
||||
{Tag: "login_logo", Title: "登录页 Logo", Remark: "显示在登录页左上角,建议透明背景 PNG | group:登录页设置 | type:image"},
|
||||
{Tag: "login_bg", Title: "登录页背景图", Remark: "登录页左侧背景图,建议浅色系、竖版 1200×1600 以上 | group:登录页设置 | type:image"},
|
||||
}
|
||||
|
||||
// rePlatformSiteTag 标签名校验:与 tagengine 全局标签正则保持一致
|
||||
|
||||
@@ -400,6 +400,10 @@ func Register() {
|
||||
beego.Router("/platform/website/setting/info", &controllers.PlatformWebsiteSettingController{}, "get:Info")
|
||||
beego.Router("/platform/website/setting/save", &controllers.PlatformWebsiteSettingController{}, "post:Save")
|
||||
|
||||
// 统一登录页品牌(Logo / 背景图 / 主副标题,与站点设置共用 yz_platform_site_setting)
|
||||
beego.Router("/platform/loginBrand", &controllers.PlatformWebsiteSettingController{}, "get:LoginBrandInfo")
|
||||
beego.Router("/platform/saveLoginBrand", &controllers.PlatformWebsiteSettingController{}, "post:SaveLoginBrand")
|
||||
|
||||
// ===== 平台官网管理(docs/sql/create_platform_website.sql)=====
|
||||
// 文章管理 yz_platform_website_article
|
||||
beego.Router("/platform/website/article/list", &controllers.PlatformWebsiteArticleController{}, "get:List")
|
||||
|
||||
@@ -44,14 +44,20 @@ func ResolveSoftwareDownloadURLs(scheme, host string, downloadURLs *string) map[
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolvePublicURL 把相对路径拼成公开 URL;http(s) 地址原样返回。
|
||||
// legacyStorageHosts 历史七牛 CDN 域名:这两个域名只支持 HTTP,HTTPS 页面直接引用会被
|
||||
// 浏览器以 Mixed Content 拦截,需在返回给前端前改写为当前存储配置的访问入口。
|
||||
var legacyStorageHosts = []string{"7colud.yunzer.cn", "7cloud.yunzer.cn"}
|
||||
|
||||
// ResolvePublicURL 把相对路径拼成公开 URL;http(s) 地址原样返回,
|
||||
// 但历史七牛域名会改写为当前存储配置的访问基址(如 https://yunzer.com.cn/qiniu)。
|
||||
func ResolvePublicURL(scheme, host, src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(src), "http://") || strings.HasPrefix(strings.ToLower(src), "https://") {
|
||||
return src
|
||||
lowerSrc := strings.ToLower(src)
|
||||
if strings.HasPrefix(lowerSrc, "http://") || strings.HasPrefix(lowerSrc, "https://") {
|
||||
return rewriteLegacyStorageURL(src)
|
||||
}
|
||||
if host == "" {
|
||||
return src
|
||||
@@ -65,6 +71,37 @@ func ResolvePublicURL(scheme, host, src string) string {
|
||||
return scheme + "://" + host + src
|
||||
}
|
||||
|
||||
// rewriteLegacyStorageURL 把历史七牛域名的 scheme+host 换成当前存储配置域名,
|
||||
// 保留对象路径不变;非历史域名或配置缺失时原样返回。
|
||||
func rewriteLegacyStorageURL(src string) string {
|
||||
cfg, err := models.GetStorageConfig()
|
||||
if err != nil {
|
||||
return src
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.QiniuDomain), "/")
|
||||
if base == "" {
|
||||
return src
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
base = "https://" + base
|
||||
}
|
||||
lowerSrc := strings.ToLower(src)
|
||||
for _, legacyHost := range legacyStorageHosts {
|
||||
for _, scheme := range []string{"http://", "https://"} {
|
||||
prefix := scheme + legacyHost
|
||||
if !strings.HasPrefix(lowerSrc, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := src[len(prefix):]
|
||||
// 防止 7colud.yunzer.cn.example.com 这类前缀误匹配
|
||||
if rest == "" || strings.HasPrefix(rest, "/") {
|
||||
return base + rest
|
||||
}
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// ResolveSoftwareDownloadURL 优先使用自定义 download_url;否则根据 file_id 读附件 src 拼完整 URL
|
||||
func ResolveSoftwareDownloadURL(scheme, host string, downloadURL *string, fileID *uint64) string {
|
||||
if downloadURL != nil {
|
||||
|
||||
+426
-203
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>统一认证中心 - 登录</title>
|
||||
<title>{{.LoginTitle}} - 统一认证中心</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3973ff;
|
||||
@@ -14,7 +14,7 @@
|
||||
--text-sub: #5b6780;
|
||||
--muted: #8a94ad;
|
||||
--border: #e3e9f5;
|
||||
--radius: 12px;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -23,124 +23,189 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(120deg, #eef4ff 0%, #f7fbff 55%, #eaf3ff 100%);
|
||||
background: #f3f6fc;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.glow-1 { width: 380px; height: 380px; top: -120px; left: -100px; background: rgba(82, 168, 255, 0.28); }
|
||||
.glow-2 { width: 320px; height: 320px; bottom: -120px; right: -80px; background: rgba(57, 115, 255, 0.20); }
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 940px;
|
||||
min-height: 560px;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 50px 0 rgba(37, 96, 169, 0.14), 0 2px 6px 0 rgba(31, 43, 72, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-page { display: flex; min-height: 100vh; width: 100%; }
|
||||
|
||||
/* ---------- 左侧品牌区 ---------- */
|
||||
.auth-side {
|
||||
width: 340px;
|
||||
flex: 0 0 340px;
|
||||
padding: 44px 34px 28px;
|
||||
background: linear-gradient(160deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
.brand-panel {
|
||||
position: relative;
|
||||
flex: 1 1 58%;
|
||||
min-height: 100vh;
|
||||
padding: 64px 72px 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(150deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.auth-side::before {
|
||||
.brand-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 260px; height: 260px;
|
||||
top: -90px; right: -110px;
|
||||
width: 420px; height: 420px;
|
||||
top: -150px; right: -160px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
.auth-side::after {
|
||||
.brand-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 190px; height: 190px;
|
||||
bottom: -70px; left: -60px;
|
||||
width: 300px; height: 300px;
|
||||
bottom: -120px; left: -100px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
/* 配置了背景图:浅色底 + 深色文字,叠一层白色蒙版保证可读性 */
|
||||
.brand-panel.has-bg { background: #eaf1fb; color: var(--text); }
|
||||
.brand-panel.has-bg::before,
|
||||
.brand-panel.has-bg::after { display: none; }
|
||||
.brand-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.brand-veil {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.46) 52%, rgba(255, 255, 255, 0.10) 100%);
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.brand-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 54px; }
|
||||
.brand-logo {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 11px;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-logo svg { width: 22px; height: 22px; }
|
||||
.brand-name { font-size: 16px; font-weight: 600; letter-spacing: .5px; }
|
||||
.brand-logo svg { width: 24px; height: 24px; }
|
||||
.brand-name { font-size: 19px; font-weight: 600; letter-spacing: .5px; }
|
||||
.brand-img { height: 44px; max-width: 240px; object-fit: contain; display: block; }
|
||||
|
||||
.side-title {
|
||||
margin-top: 44px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
position: relative; z-index: 1;
|
||||
.brand-title {
|
||||
font-size: clamp(34px, 3.6vw, 52px);
|
||||
font-weight: 700;
|
||||
line-height: 1.28;
|
||||
letter-spacing: 1px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.side-desc {
|
||||
margin-top: 12px;
|
||||
font-size: 13.5px;
|
||||
.brand-sub {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
position: relative; z-index: 1;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
max-width: 520px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.brand-panel.has-bg .brand-sub { color: var(--text-sub); }
|
||||
.brand-panel.has-bg .brand-name { color: var(--text); }
|
||||
.brand-panel.has-bg .brand-logo {
|
||||
background: rgba(57, 115, 255, 0.12);
|
||||
border-color: rgba(57, 115, 255, 0.18);
|
||||
}
|
||||
.brand-panel.has-bg .brand-logo svg { stroke: var(--primary); }
|
||||
|
||||
.side-illus { margin: auto 0; padding: 22px 0; position: relative; z-index: 1; }
|
||||
.side-illus svg { width: 100%; height: auto; display: block; }
|
||||
|
||||
.side-foot {
|
||||
position: relative; z-index: 1;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||
.brand-foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 72px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.side-foot svg { width: 13px; height: 13px; }
|
||||
.brand-panel.has-bg .brand-foot { color: var(--muted); }
|
||||
.brand-foot svg { width: 13px; height: 13px; flex: 0 0 auto; }
|
||||
|
||||
/* ---------- 右侧表单区 ---------- */
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 52px 56px 40px;
|
||||
.form-panel {
|
||||
position: relative;
|
||||
flex: 0 0 42%;
|
||||
min-width: 460px;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 56px 88px;
|
||||
}
|
||||
.form-inner { width: 100%; max-width: 380px; }
|
||||
|
||||
.panel-head { margin-bottom: 30px; }
|
||||
.panel-title { font-size: 30px; font-weight: 700; letter-spacing: .5px; }
|
||||
.panel-sub { margin-top: 10px; font-size: 13.5px; color: var(--text-sub); line-height: 1.7; }
|
||||
|
||||
/* ---------- 登录方式 tab ---------- */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0 0 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.login-tab {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
padding: 11px 4px 13px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
cursor: pointer;
|
||||
transition: color .2s;
|
||||
}
|
||||
.login-tab:hover { color: var(--primary); }
|
||||
.login-tab.active { color: var(--primary); font-weight: 600; }
|
||||
.login-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px; right: 12px; bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%);
|
||||
}
|
||||
.login-panel { display: none; }
|
||||
.login-panel.active { display: block; animation: panelIn .24s ease both; }
|
||||
@keyframes panelIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.panel-head { margin-bottom: 28px; }
|
||||
.panel-title { font-size: 27px; font-weight: 600; letter-spacing: .5px; }
|
||||
.panel-sub { margin-top: 9px; font-size: 13.5px; color: var(--text-sub); }
|
||||
/* 小屏时左侧品牌区隐藏,在表单上方展示紧凑品牌头 */
|
||||
.mobile-brand { display: none; align-items: center; gap: 12px; margin-bottom: 28px; }
|
||||
.mobile-brand .brand-img { height: 36px; max-width: 180px; }
|
||||
.mobile-brand .brand-logo {
|
||||
width: 40px; height: 40px;
|
||||
background: rgba(57, 115, 255, 0.10);
|
||||
border-color: rgba(57, 115, 255, 0.18);
|
||||
}
|
||||
.mobile-brand .brand-logo svg { stroke: var(--primary); }
|
||||
.mobile-brand-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mobile-brand-name { font-size: 15px; font-weight: 600; }
|
||||
.mobile-brand-sub { font-size: 12px; color: var(--muted); }
|
||||
|
||||
.form-foot {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 26px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #a3acbf;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: none;
|
||||
@@ -157,6 +222,8 @@
|
||||
animation: shake .32s ease;
|
||||
}
|
||||
.error.show { display: flex; }
|
||||
/* 中性的提示语气(功能未上线 / 操作引导) */
|
||||
.error.is-info { background: #f2f7ff; border-color: #d2e2ff; color: #2b5fc7; }
|
||||
.error svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 2px; }
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
@@ -280,8 +347,8 @@
|
||||
.divider span { position: relative; background: #fff; padding: 0 12px; }
|
||||
.third-list { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.third-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
@@ -296,6 +363,39 @@
|
||||
background: #f5f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* 第三方图标色块:微信绿 / QQ 蓝 / 其余统一主色 */
|
||||
.third-icon {
|
||||
width: 22px; height: 22px;
|
||||
flex: 0 0 22px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 9.5px; font-weight: 700; letter-spacing: -.2px;
|
||||
}
|
||||
.third-icon svg { width: 13px; height: 13px; }
|
||||
.third-icon.is-wechat { background: rgba(7, 193, 96, .12); color: #07c160; }
|
||||
.third-icon.is-qq { background: rgba(18, 183, 245, .14); color: #12b7f5; }
|
||||
.third-icon.is-generic { background: rgba(57, 115, 255, .10); color: var(--primary); }
|
||||
|
||||
/* 短信验证码 */
|
||||
.code-row { display: flex; align-items: center; gap: 10px; }
|
||||
.code-row .input-wrap { flex: 1 1 auto; }
|
||||
.code-btn {
|
||||
flex: 0 0 auto;
|
||||
height: 46px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(57, 115, 255, .28);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(57, 115, 255, .06);
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background .2s, border-color .2s, color .2s;
|
||||
}
|
||||
.code-btn:hover { background: rgba(57, 115, 255, .12); border-color: rgba(57, 115, 255, .45); }
|
||||
.code-btn:disabled { background: #f5f7fb; border-color: var(--border); color: var(--muted); cursor: not-allowed; }
|
||||
.form-hint { margin-top: 14px; font-size: 12px; color: var(--muted); line-height: 1.8; text-align: center; }
|
||||
|
||||
/* 企业选择 */
|
||||
.tenant-list { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
||||
@@ -333,27 +433,38 @@
|
||||
.tenant-arrow svg { width: 18px; height: 18px; display: block; }
|
||||
.tenant-item:hover .tenant-arrow { color: var(--primary); transform: translateX(3px); }
|
||||
|
||||
.back { text-align: center; margin-top: 22px; font-size: 13px; }
|
||||
.back { text-align: center; margin-top: 18px; font-size: 13px; color: var(--text-sub); line-height: 1.7; }
|
||||
|
||||
/* 微信扫码登录 */
|
||||
.scan-flex { display: flex; justify-content: center; }
|
||||
.scan-qr {
|
||||
width: 180px; height: 180px; flex: 0 0 180px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
/* ---------- 扫码登录 ---------- */
|
||||
.qr-panel { padding: 4px 0 2px; text-align: center; }
|
||||
.qr-box {
|
||||
position: relative;
|
||||
width: 200px; height: 200px;
|
||||
margin: 0 auto 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(31, 43, 72, 0.08);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-qr .qr-img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.scan-qr .qr-mask {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
font-size: 12px; color: var(--muted); text-align: center; padding: 8px;
|
||||
.qr-img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.qr-mask {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, .96);
|
||||
font-size: 12.5px; color: var(--text-sub); line-height: 1.6; text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.scan-btn {
|
||||
border: none; background: var(--primary); color: #fff;
|
||||
border-radius: 8px; padding: 6px 16px; font-size: 12px; cursor: pointer;
|
||||
border-radius: 8px; padding: 7px 18px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.scan-right { display: none; }
|
||||
.qr-title { font-size: 14.5px; font-weight: 600; color: var(--text); letter-spacing: .3px; }
|
||||
.qr-desc { margin-top: 8px; font-size: 12.5px; line-height: 1.9; color: var(--text-sub); }
|
||||
.qr-desc strong { color: var(--primary); font-weight: 600; }
|
||||
.qr-note { margin-top: 12px; font-size: 12px; color: var(--muted); }
|
||||
|
||||
.foot-tip {
|
||||
margin-top: 26px;
|
||||
@@ -365,56 +476,57 @@
|
||||
}
|
||||
.foot-tip svg { width: 13px; height: 13px; flex: 0 0 auto; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.brand-panel { padding: 48px 44px; }
|
||||
.brand-title { font-size: 34px; }
|
||||
.form-panel { min-width: 400px; padding: 56px 40px 84px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 20px 14px; align-items: flex-start; }
|
||||
.auth-card { max-width: 520px; min-height: 0; flex-direction: column; }
|
||||
.auth-side { width: 100%; flex: none; padding: 30px 26px 24px; }
|
||||
.side-title { margin-top: 26px; font-size: 21px; }
|
||||
.side-illus { display: none; }
|
||||
.auth-main { padding: 34px 26px 30px; }
|
||||
.panel-title { font-size: 23px; }
|
||||
.login-page { flex-direction: column; }
|
||||
.brand-panel { display: none; }
|
||||
.form-panel {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
align-items: flex-start;
|
||||
padding: 40px 24px 72px;
|
||||
}
|
||||
.form-inner { max-width: 440px; margin: 0 auto; }
|
||||
.mobile-brand { display: flex; }
|
||||
.panel-title { font-size: 26px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
<div class="login-page">
|
||||
<!-- 左侧品牌区:背景图 / Logo / 主副标题由 platform「平台设置 → 登录页设置」提供 -->
|
||||
<aside class="brand-panel{{if .LoginBg}} has-bg{{end}}">
|
||||
{{if .LoginBg}}
|
||||
<img class="brand-bg" src="{{.LoginBg}}" alt="" />
|
||||
<span class="brand-veil"></span>
|
||||
{{end}}
|
||||
|
||||
<div class="auth-card">
|
||||
<!-- 左侧品牌区 -->
|
||||
<aside class="auth-side">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
<path d="M9.2 12.2l2 2 3.6-4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-name">云泽统一认证中心</span>
|
||||
<div class="brand-inner">
|
||||
<div class="brand">
|
||||
{{if .LoginLogo}}
|
||||
<img class="brand-img" src="{{.LoginLogo}}" alt="{{.LoginTitle}}" />
|
||||
{{else}}
|
||||
<span class="brand-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
<path d="M9.2 12.2l2 2 3.6-4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-name">云泽统一认证中心</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h1 class="brand-title">{{.LoginTitle}}</h1>
|
||||
<p class="brand-sub">{{.LoginSubtitle}}</p>
|
||||
</div>
|
||||
|
||||
<div class="side-title">一次登录<br>全平台通行</div>
|
||||
<div class="side-desc">
|
||||
基于 OIDC / OAuth 2.0 的统一身份底座,<br>
|
||||
为所有接入应用提供安全、一致的登录体验。
|
||||
</div>
|
||||
|
||||
<div class="side-illus">
|
||||
<svg viewBox="0 0 260 170" fill="none">
|
||||
<ellipse cx="130" cy="150" rx="98" ry="11" fill="rgba(255,255,255,.13)"/>
|
||||
<rect x="26" y="60" width="56" height="38" rx="9" fill="rgba(255,255,255,.14)"/>
|
||||
<rect x="36" y="70" width="26" height="4" rx="2" fill="rgba(255,255,255,.45)"/>
|
||||
<rect x="36" y="79" width="36" height="4" rx="2" fill="rgba(255,255,255,.28)"/>
|
||||
<rect x="184" y="52" width="50" height="34" rx="9" fill="rgba(255,255,255,.10)"/>
|
||||
<rect x="193" y="62" width="22" height="4" rx="2" fill="rgba(255,255,255,.36)"/>
|
||||
<path d="M130 26l40 15v30c0 22-17 39-40 47-23-8-40-25-40-47V41l40-15z"
|
||||
fill="rgba(255,255,255,.17)" stroke="rgba(255,255,255,.55)" stroke-width="1.6"/>
|
||||
<path d="M116 82l11 11 21-23" stroke="#fff" stroke-width="5.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<div class="brand-foot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||
<path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
@@ -424,7 +536,24 @@
|
||||
</aside>
|
||||
|
||||
<!-- 右侧表单区 -->
|
||||
<main class="auth-main">
|
||||
<main class="form-panel">
|
||||
<div class="form-inner">
|
||||
<div class="mobile-brand">
|
||||
{{if .LoginLogo}}
|
||||
<img class="brand-img" src="{{.LoginLogo}}" alt="{{.LoginTitle}}" />
|
||||
{{else}}
|
||||
<span class="brand-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
<path d="M9.2 12.2l2 2 3.6-4"/>
|
||||
</svg>
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="mobile-brand-text">
|
||||
<span class="mobile-brand-name">云泽统一认证中心</span>
|
||||
<span class="mobile-brand-sub">{{.LoginTitle}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{{if eq .Step "tenant"}}
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title">选择企业</h1>
|
||||
@@ -462,21 +591,10 @@
|
||||
<div class="back"><a class="link" onclick="logout()">不是我的账号?切换账号</a></div>
|
||||
{{else}}
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title">欢迎登录</h1>
|
||||
<p class="panel-sub">请输入您的账号与密码以继续</p>
|
||||
<h1 class="panel-title">登录</h1>
|
||||
<p class="panel-sub">请选择登录方式,进入您的工作空间</p>
|
||||
</div>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="notice-box{{if eq .Notice "registered_warn"}} warn{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{{if eq .Notice "registered_warn"}}<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>{{else}}<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/><path d="M9.2 12.2l2 2 3.6-4"/>{{end}}
|
||||
</svg>
|
||||
<span>
|
||||
{{if eq .Notice "reset"}}密码已重置,请使用新密码登录{{else if eq .Notice "registered_warn"}}{{.WarnMsg}},可先登录{{else}}注册成功,请使用新账号登录{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="error" id="error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>
|
||||
@@ -484,7 +602,27 @@
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="return submitLogin(event)" autocomplete="on">
|
||||
<!-- 登录方式切换:默认账号密码 -->
|
||||
<div class="login-tabs" role="tablist">
|
||||
<button class="login-tab active" type="button" role="tab" data-tab="account" onclick="switchLoginTab('account')">账号密码</button>
|
||||
<button class="login-tab" type="button" role="tab" data-tab="sms" onclick="switchLoginTab('sms')">短信登录</button>
|
||||
<button class="login-tab" type="button" role="tab" data-tab="qr" onclick="switchLoginTab('qr')">扫码登录</button>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<div class="login-panel active" id="panel-account">
|
||||
{{if .Notice}}
|
||||
<div class="notice-box{{if eq .Notice "registered_warn"}} warn{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{{if eq .Notice "registered_warn"}}<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>{{else}}<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/><path d="M9.2 12.2l2 2 3.6-4"/>{{end}}
|
||||
</svg>
|
||||
<span>
|
||||
{{if eq .Notice "reset"}}密码已重置,请使用新密码登录{{else if eq .Notice "registered_warn"}}{{.WarnMsg}},可先登录{{else}}注册成功,请使用新账号登录{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form id="loginForm" onsubmit="return submitLogin(event)" autocomplete="on">
|
||||
<div class="field">
|
||||
<label class="field-label" for="account">账号</label>
|
||||
<div class="input-wrap">
|
||||
@@ -524,46 +662,82 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="back" style="margin-top:16px">还没有账号?<a class="link" href="/auth/register?client_id={{urlquery .ClientID}}&redirect={{urlquery .Redirect}}">立即注册</a></div>
|
||||
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
<div class="back">还没有账号?<a class="link" href="/auth/register?client_id={{urlquery .ClientID}}&redirect={{urlquery .Redirect}}">立即注册</a></div>
|
||||
</div>
|
||||
|
||||
<!-- 微信扫码登录(未认证公众号:服务号二维码 + 登录码,公众号内确认后自动登录) -->
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" style="justify-content:flex-start">
|
||||
<button class="third-btn" type="button" id="wechatScanEntry" onclick="showWechatScan()">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16v12H8l-4 4z"/><path d="M8.5 9.5h.01M12 9.5h.01M15.5 9.5h.01"/>
|
||||
</svg>
|
||||
微信登录
|
||||
</button>
|
||||
<!-- 手机号验证码登录 -->
|
||||
<div class="login-panel" id="panel-sms">
|
||||
<form onsubmit="return submitSmsLogin(event)" autocomplete="on">
|
||||
<div class="field">
|
||||
<label class="field-label" for="smsMobile">手机号</label>
|
||||
<div class="input-wrap">
|
||||
<input id="smsMobile" type="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" autocomplete="tel">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M11 18h2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="smsCode">验证码</label>
|
||||
<div class="code-row">
|
||||
<div class="input-wrap">
|
||||
<input id="smsCode" type="text" inputmode="numeric" maxlength="6" placeholder="请输入短信验证码" autocomplete="one-time-code">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M8 9h8M8 13h5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<button class="code-btn" type="button" id="smsCodeBtn" onclick="sendSmsCode()">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit" id="smsSubmitBtn">
|
||||
<span class="spinner"></span>
|
||||
<span id="smsBtnLabel">登 录</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="form-hint">未注册的手机号验证通过后将自动创建账号</p>
|
||||
</div>
|
||||
|
||||
<div id="wechatScanArea" style="display:none; margin-top:18px;">
|
||||
<div class="scan-flex">
|
||||
<div class="scan-qr">
|
||||
<!-- 扫码登录:微信扫码 → 小程序内确认 -->
|
||||
<div class="login-panel" id="panel-qr">
|
||||
<div class="qr-panel">
|
||||
<div class="qr-box">
|
||||
<img id="scanQrImg" class="qr-img" alt="微信扫码登录" style="display:none" />
|
||||
<div class="qr-mask" id="scanQrMask">
|
||||
<span id="scanQrMaskText">正在获取二维码…</span>
|
||||
<button class="scan-btn" onclick="restartWechatScan()" style="display:none" id="scanQrRefreshBtn">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-title">微信扫码登录</p>
|
||||
<p class="qr-desc">使用微信「扫一扫」,扫码后在<strong>云泽小程序</strong>中点击确认<br />即可完成登录</p>
|
||||
<p class="qr-note">二维码 5 分钟内有效,过期可点击刷新</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三方登录(未配置时点击提示即将上线) -->
|
||||
<div class="third-area" id="thirdArea">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList">
|
||||
<button class="third-btn" type="button" data-third-provider="wechat" onclick="thirdLogin('wechat', '微信')">
|
||||
<span class="third-icon is-wechat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 5h13v9H9l-4 4z"/><path d="M9 9h.01M13 9h.01"/>
|
||||
</svg>
|
||||
</span>
|
||||
微信登录
|
||||
</button>
|
||||
<button class="third-btn" type="button" data-third-provider="qq" onclick="thirdLogin('qq', 'QQ')">
|
||||
<span class="third-icon is-qq">QQ</span>
|
||||
QQ 登录
|
||||
</button>
|
||||
</div>
|
||||
<div class="back"><a class="link" onclick="hideWechatScan()">返回账号密码登录</a></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="foot-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
</svg>
|
||||
<!--
|
||||
<span>统一身份认证 · 一次登录,全平台通行</span>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<div class="form-foot">© 2026 云泽科技 版权所有</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -578,9 +752,12 @@
|
||||
var GEETEST_SDK_LOCAL = '/static/js/gt4.js';
|
||||
var geetestSDKPromise = null;
|
||||
|
||||
function showError(msg) {
|
||||
function showError(msg, tone) {
|
||||
var box = document.getElementById('error');
|
||||
document.getElementById('errorText').textContent = msg;
|
||||
var text = document.getElementById('errorText');
|
||||
if (!box || !text) return;
|
||||
box.classList.toggle('is-info', tone === 'info');
|
||||
text.textContent = msg;
|
||||
box.classList.remove('show');
|
||||
void box.offsetWidth; // 重启动画
|
||||
box.classList.add('show');
|
||||
@@ -591,6 +768,24 @@
|
||||
if (box) box.classList.remove('show');
|
||||
}
|
||||
|
||||
// ==================== 登录方式切换(账号密码 / 短信 / 扫码) ====================
|
||||
var LOGIN_PANELS = { account: 'panel-account', sms: 'panel-sms', qr: 'panel-qr' };
|
||||
|
||||
function switchLoginTab(name) {
|
||||
if (!LOGIN_PANELS[name]) return;
|
||||
var tabs = document.querySelectorAll('.login-tab');
|
||||
Array.prototype.forEach.call(tabs, function (tab) {
|
||||
tab.classList.toggle('active', tab.getAttribute('data-tab') === name);
|
||||
});
|
||||
Object.keys(LOGIN_PANELS).forEach(function (key) {
|
||||
var panel = document.getElementById(LOGIN_PANELS[key]);
|
||||
if (panel) panel.classList.toggle('active', key === name);
|
||||
});
|
||||
hideError();
|
||||
// 扫码 tab 才拉二维码,离开则停止轮询
|
||||
if (name === 'qr') restartWechatScan(); else stopWechatScan();
|
||||
}
|
||||
|
||||
function setBtnLoading(on, text) {
|
||||
var btn = document.getElementById('submitBtn');
|
||||
if (!btn) return;
|
||||
@@ -682,28 +877,50 @@
|
||||
|
||||
loadVerifyConfig();
|
||||
|
||||
// 加载已开通的第三方登录方式
|
||||
// 已开通的第三方登录方式:记录状态,供静态 QQ / 微信入口判断是否可用
|
||||
var thirdEnabled = {};
|
||||
var THIRD_STATIC = { wechat: 1, qq: 1 };
|
||||
|
||||
function loadThirdProviders() {
|
||||
var box = document.getElementById('thirdList');
|
||||
if (!box) return;
|
||||
fetch('/auth/third/list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var list = (res && res.data) || [];
|
||||
// 微信走「公众号扫码登录」独立入口,第三方列表里不再重复展示
|
||||
var enabled = list.filter(function (i) { return i.enabled && i.provider !== 'wechat'; });
|
||||
var box = document.getElementById('thirdList');
|
||||
box.innerHTML = ''; // 防止重复渲染
|
||||
if (!enabled.length) return;
|
||||
enabled.forEach(function (item) {
|
||||
thirdEnabled = {};
|
||||
// 清掉上一轮动态追加的按钮(静态 QQ / 微信入口保留)
|
||||
Array.prototype.slice.call(box.querySelectorAll('[data-dynamic="1"]')).forEach(function (n) {
|
||||
if (n.parentNode) n.parentNode.removeChild(n);
|
||||
});
|
||||
list.forEach(function (item) {
|
||||
if (!item || !item.enabled) return;
|
||||
thirdEnabled[item.provider] = true;
|
||||
if (THIRD_STATIC[item.provider]) return;
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'third-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = item.name;
|
||||
btn.className = 'third-btn';
|
||||
btn.setAttribute('data-dynamic', '1');
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'third-icon is-generic';
|
||||
icon.textContent = (item.name || '?').slice(0, 1);
|
||||
btn.appendChild(icon);
|
||||
btn.appendChild(document.createTextNode(item.name || item.provider));
|
||||
btn.onclick = function () { startThird(item.provider); };
|
||||
box.appendChild(btn);
|
||||
});
|
||||
document.getElementById('thirdArea').style.display = 'block';
|
||||
// 未开通的入口:保留样式,悬停提示即将上线,点击给中性提示
|
||||
Array.prototype.forEach.call(box.querySelectorAll('[data-third-provider]'), function (btn) {
|
||||
var provider = btn.getAttribute('data-third-provider');
|
||||
btn.title = thirdEnabled[provider] ? '' : '即将上线';
|
||||
});
|
||||
})
|
||||
.catch(function () { /* 静默失败,不影响账号密码登录 */ });
|
||||
.catch(function () { /* 静默失败,不影响其他登录方式 */ });
|
||||
}
|
||||
|
||||
function thirdLogin(provider, label) {
|
||||
if (thirdEnabled[provider]) { startThird(provider); return; }
|
||||
showError((label || provider) + '登录即将上线,敬请期待', 'info');
|
||||
}
|
||||
|
||||
function startThird(provider) {
|
||||
@@ -772,6 +989,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 手机号验证码登录(仅样式,接口待接入) ====================
|
||||
function sendSmsCode() {
|
||||
var input = document.getElementById('smsMobile');
|
||||
var mobile = input ? input.value.trim() : '';
|
||||
if (!/^1[3-9]\d{9}$/.test(mobile)) { showError('请输入正确的手机号'); return; }
|
||||
showError('短信验证码服务即将上线,请先使用账号密码登录', 'info');
|
||||
}
|
||||
|
||||
function submitSmsLogin(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
var mobileEl = document.getElementById('smsMobile');
|
||||
var codeEl = document.getElementById('smsCode');
|
||||
var mobile = mobileEl ? mobileEl.value.trim() : '';
|
||||
var code = codeEl ? codeEl.value.trim() : '';
|
||||
if (!/^1[3-9]\d{9}$/.test(mobile)) { showError('请输入正确的手机号'); return false; }
|
||||
if (!code) { showError('请输入验证码'); return false; }
|
||||
showError('手机号验证码登录即将上线,请先使用账号密码登录', 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
@@ -867,27 +1105,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 微信扫码登录(未认证公众号:服务号二维码 + 登录码) ====================
|
||||
// ==================== 微信扫码登录(扫码 → 小程序内确认;已接 scan/start、scan/status) ====================
|
||||
var scanScene = '';
|
||||
var scanExpireAt = 0;
|
||||
var scanPollTimer = null;
|
||||
|
||||
function showWechatScan() {
|
||||
hideError();
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('thirdArea').style.display = 'none';
|
||||
document.getElementById('wechatScanEntry').parentElement.style.display = 'none';
|
||||
document.getElementById('wechatScanArea').style.display = 'block';
|
||||
restartWechatScan();
|
||||
}
|
||||
|
||||
function hideWechatScan() {
|
||||
stopWechatScan();
|
||||
document.getElementById('wechatScanArea').style.display = 'none';
|
||||
document.getElementById('loginForm').style.display = 'block';
|
||||
document.getElementById('wechatScanEntry').parentElement.style.display = 'flex';
|
||||
loadThirdProviders();
|
||||
}
|
||||
// 显隐由 switchLoginTab 控制,切换 tab 时自动启停轮询
|
||||
|
||||
function stopWechatScan() {
|
||||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null; }
|
||||
|
||||
Reference in new issue
Block a user