更新日报相关功能

This commit is contained in:
hero920103 committed 2026-10-22 17:55:50 +08:00
1 parent 51fd90fc51
commit 68cdaf38fa
23 files changed
+2313 -1056

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TenantSwitcher: typeof import('./src/components/TenantSwitcher.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+26 -7
View File
@@ -3,22 +3,41 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<title>正在完成登录…</title>
<link rel="preload" as="image" href="/loading.gif" />
<style>
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; color: #5a6072; font-size: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #fff;
}
.box {
background: #fff; border-radius: 12px; padding: 32px 40px;
box-shadow: 0 20px 60px rgba(0,0,0,.2); text-align: center;
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-gif {
display: block;
width: 160px;
max-width: 45vw;
height: auto;
}
.loading-text {
margin: 6px 0 0;
font-size: 14px;
color: #8a8f9c;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<div class="box">正在完成登录,请稍候…</div>
<!-- 登录回跳中转:仅做 code 转发,画面保持白底 + 居中 loading -->
<div class="loading-wrap">
<img class="loading-gif" src="/loading.gif" alt="登录中,请稍后…" />
<p class="loading-text">登录中,请稍后…</p>
</div>
<script>
/**
* 统一认证中心回调中转页。
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

@@ -69,15 +69,51 @@
</div>
<section class="detail-section">
<h4>今日完成</h4>
<h4 class="section-title">
<span>今日完成</span>
<el-button
link
type="primary"
size="small"
:icon="CopyDocument"
:disabled="!detail.report.today_done?.trim()"
@click="copySection('今日完成', detail.report.today_done)"
>
复制
</el-button>
</h4>
<div class="section-text">{{ detail.report.today_done || '—' }}</div>
</section>
<section class="detail-section">
<h4>明日计划</h4>
<h4 class="section-title">
<span>明日计划</span>
<el-button
link
type="primary"
size="small"
:icon="CopyDocument"
:disabled="!detail.report.tomorrow_plan?.trim()"
@click="copySection('明日计划', detail.report.tomorrow_plan)"
>
复制
</el-button>
</h4>
<div class="section-text">{{ detail.report.tomorrow_plan || '—' }}</div>
</section>
<section class="detail-section">
<h4>问题与建议</h4>
<h4 class="section-title">
<span>问题与建议</span>
<el-button
link
type="primary"
size="small"
:icon="CopyDocument"
:disabled="!detail.report.issues?.trim()"
@click="copySection('问题与建议', detail.report.issues)"
>
复制
</el-button>
</h4>
<div class="section-text">{{ detail.report.issues || '—' }}</div>
</section>
@@ -200,7 +236,7 @@
<script setup>
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Paperclip } from '@element-plus/icons-vue'
import { CopyDocument, Paperclip } from '@element-plus/icons-vue'
import {
getDailyReportDetail,
saveDailyReport,
@@ -373,6 +409,32 @@ async function handleDeleteReport() {
}
}
// 一键复制单个板块的内容,非 HTTPS 环境回退到 textarea 复制。
async function copySection(label, text) {
const content = (text || '').trim()
if (!content) {
ElMessage.warning(`「${label}」暂无内容可复制`)
return
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
} else {
const textarea = document.createElement('textarea')
textarea.value = content
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`「${label}」已复制到剪贴板`)
} catch {
ElMessage.error('复制失败,请手动选择内容复制')
}
}
function formatSize(size) {
const n = Number(size) || 0
if (n < 1024) return `${n} B`
@@ -484,6 +546,13 @@ function formatSize(size) {
margin: 0 0 8px;
font-size: 14px;
color: #606266;
&.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
}
@@ -547,9 +547,11 @@ function cellClass(cell) {
return cell.isFuture ? 'is-future' : 'is-none'
}
// 补写窗口内、尚未写日报的日期:格子上标注「补」,提示可直接补写
// 补写窗口内、往日尚未写日报的日期:格子上标注「补」,提示可直接补写
// 今天属于正常填写,未写时不标「补」
function cellBackfill(cell) {
if (!cell || cell.isFuture || cell.record) return false
if (cell.date === todayStr()) return false
return inWindow(cell.date)
}
@@ -557,6 +559,7 @@ function cellTooltip(cell) {
if (!cell) return ''
if (!cell.record) {
if (cell.isFuture) return `${cell.date}(未到)`
if (cell.date === todayStr()) return `${cell.date}(今日未填写,点击填写)`
if (cellBackfill(cell)) return `${cell.date}(可补写,点击填写)`
return `${cell.date}(未填写)`
}
+44 -41
View File
@@ -1,29 +1,27 @@
<template>
<div class="mp-login">
<div class="box">
<!-- 企业选择(一人多企业) -->
<template v-if="choosing">
<p class="title">选择要进入的企业</p>
<ul class="tenant-list">
<li v-for="t in tenants" :key="t.tid" class="tenant-item" @click="chooseTenant(t)">
<span class="t-name">{{ t.tenant_name }}</span>
<span v-if="t.name" class="t-meta">{{ t.name }}</span>
</li>
</ul>
<p v-if="chooseErr" class="msg err">{{ chooseErr }}</p>
</template>
<!-- 企业选择(一人多企业) -->
<div v-if="choosing" class="box">
<p class="title">选择要进入的企业</p>
<ul class="tenant-list">
<li v-for="t in tenants" :key="t.tid" class="tenant-item" @click="chooseTenant(t)">
<span class="t-name">{{ t.tenant_name }}</span>
<span v-if="t.name" class="t-meta">{{ t.name }}</span>
</li>
</ul>
<p v-if="chooseErr" class="msg err">{{ chooseErr }}</p>
</div>
<template v-else>
<div v-if="error" class="error">
<p class="title">免登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="goHome">返回首页</button>
</div>
<div v-else class="loading">
<div class="spinner"></div>
<p>正在同步登录状态,请稍候…</p>
</div>
</template>
<!-- 正在同步登录态:白底 + 居中 loading -->
<div v-else-if="!error" class="loading-wrap">
<img class="loading-gif" src="/loading.gif" alt="登录中,请稍后…" />
<p class="loading-text">登录中,请稍后…</p>
</div>
<div v-else class="box">
<p class="title">免登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="goHome">返回首页</button>
</div>
</div>
</template>
@@ -148,29 +146,34 @@ function goHome() {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #fff;
}
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-gif {
display: block;
width: 160px;
max-width: 45vw;
height: auto;
}
.loading-text {
margin: 6px 0 0;
font-size: 14px;
color: #8a8f9c;
letter-spacing: 0.5px;
}
.box {
background: #fff;
border: 1px solid #eef0f5;
border-radius: 12px;
padding: 40px 36px;
padding: 36px 32px;
width: 360px;
max-width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 36px;
height: 36px;
margin: 0 auto 16px;
border: 3px solid #e4e7ee;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
box-shadow: 0 12px 32px rgba(31, 43, 72, 0.08);
}
.title {
font-size: 16px;
@@ -225,7 +228,7 @@ function goHome() {
border: none;
border-radius: 8px;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #fff;
font-size: 14px;
}
+31 -28
View File
@@ -1,15 +1,13 @@
<template>
<div class="auth-callback">
<div class="box">
<div v-if="error" class="error">
<p class="title">登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="retry">重新登录</button>
</div>
<div v-else class="loading">
<div class="spinner"></div>
<p>正在完成登录,请稍候…</p>
</div>
<div v-if="!error" class="loading-wrap">
<img class="loading-gif" src="/loading.gif" alt="登录中,请稍后…" />
<p class="loading-text">登录中,请稍后…</p>
</div>
<div v-else class="box">
<p class="title">登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="retry">重新登录</button>
</div>
</div>
</template>
@@ -83,29 +81,34 @@ onMounted(finish);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #fff;
}
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-gif {
display: block;
width: 160px;
max-width: 45vw;
height: auto;
}
.loading-text {
margin: 6px 0 0;
font-size: 14px;
color: #8a8f9c;
letter-spacing: 0.5px;
}
.box {
background: #fff;
border: 1px solid #eef0f5;
border-radius: 12px;
padding: 40px 36px;
padding: 36px 32px;
width: 360px;
max-width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 36px;
height: 36px;
margin: 0 auto 16px;
border: 3px solid #e4e7ee;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
box-shadow: 0 12px 32px rgba(31, 43, 72, 0.08);
}
.title {
font-size: 16px;
@@ -124,7 +127,7 @@ onMounted(finish);
border: none;
border-radius: 8px;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #fff;
font-size: 14px;
}
+44 -41
View File
@@ -1,29 +1,27 @@
<template>
<div class="mp-login">
<div class="box">
<!-- 企业选择 -->
<template v-if="choosing">
<p class="title">选择要进入的企业</p>
<ul class="tenant-list">
<li v-for="t in tenants" :key="t.tid" class="tenant-item" @click="chooseTenant(t)">
<span class="t-name">{{ t.tenant_name }}</span>
<span v-if="t.name" class="t-meta">{{ t.name }}</span>
</li>
</ul>
<p v-if="chooseErr" class="msg err">{{ chooseErr }}</p>
</template>
<!-- 企业选择 -->
<div v-if="choosing" class="box">
<p class="title">选择要进入的企业</p>
<ul class="tenant-list">
<li v-for="t in tenants" :key="t.tid" class="tenant-item" @click="chooseTenant(t)">
<span class="t-name">{{ t.tenant_name }}</span>
<span v-if="t.name" class="t-meta">{{ t.name }}</span>
</li>
</ul>
<p v-if="chooseErr" class="msg err">{{ chooseErr }}</p>
</div>
<template v-else>
<div v-if="error" class="error">
<p class="title">微信登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="retry">返回重试</button>
</div>
<div v-else class="loading">
<div class="spinner"></div>
<p>正在通过微信登录,请稍候…</p>
</div>
</template>
<!-- 正在完成登录:白底 + 居中 loading -->
<div v-else-if="!error" class="loading-wrap">
<img class="loading-gif" src="/loading.gif" alt="登录中,请稍后…" />
<p class="loading-text">登录中,请稍后…</p>
</div>
<div v-else class="box">
<p class="title">微信登录失败</p>
<p class="msg">{{ error }}</p>
<button class="btn" @click="retry">返回重试</button>
</div>
</div>
</template>
@@ -132,29 +130,34 @@ onMounted(finish);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #fff;
}
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-gif {
display: block;
width: 160px;
max-width: 45vw;
height: auto;
}
.loading-text {
margin: 6px 0 0;
font-size: 14px;
color: #8a8f9c;
letter-spacing: 0.5px;
}
.box {
background: #fff;
border: 1px solid #eef0f5;
border-radius: 12px;
padding: 40px 36px;
padding: 36px 32px;
width: 360px;
max-width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.spinner {
width: 36px;
height: 36px;
margin: 0 auto 16px;
border: 3px solid #e4e7ee;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
box-shadow: 0 12px 32px rgba(31, 43, 72, 0.08);
}
.title {
font-size: 16px;
@@ -209,7 +212,7 @@ onMounted(finish);
border: none;
border-radius: 8px;
cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #fff;
font-size: 14px;
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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 单据类型、状态、收付款方式等下拉字典
+17
View File
@@ -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"
}
+1 -1
View File
@@ -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 ----
+14 -1
View File
@@ -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
+156 -30
View File
@@ -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),
}
}
+108
View File
@@ -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
}
+6
View File
@@ -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 全局标签正则保持一致
+4
View File
@@ -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")
+40 -3
View File
@@ -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
View File
@@ -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; }
+24
View File
@@ -187,4 +187,28 @@ export function sendTestBark(data) {
method: "post",
data: data,
});
}
/**
* 获取统一登录页品牌配置(主标题 / 副标题 / Logo / 背景图)
* @returns {Promise}
*/
export function getLoginBrand() {
return request({
url: "/platform/loginBrand",
method: "get",
});
}
/**
* 保存统一登录页品牌配置
* @param {Object} data { login_title, login_subtitle, login_logo, login_bg }
* @returns {Promise}
*/
export function saveLoginBrand(data) {
return request({
url: "/platform/saveLoginBrand",
method: "post",
data: data,
});
}
@@ -0,0 +1,470 @@
<template>
<div class="login-brand-settings" v-loading="loading">
<el-form ref="formRef" :model="form" label-width="140px" class="settings-form">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<span>统一登录页品牌</span>
<span class="card-tip">
保存后用于统一认证中心登录页(/auth/login),未配置项使用默认文案
</span>
</div>
</template>
<el-form-item label="主标题" prop="login_title">
<el-input
v-model="form.login_title"
maxlength="60"
show-word-limit
placeholder="如:一次登录,全平台通行"
/>
<div class="form-tip">显示在登录页左侧背景图上方,留空使用默认文案。</div>
</el-form-item>
<el-form-item label="副标题" prop="login_subtitle">
<el-input
v-model="form.login_subtitle"
type="textarea"
:rows="2"
maxlength="120"
show-word-limit
placeholder="如:基于 OIDC / OAuth 2.0 的统一身份底座"
/>
<div class="form-tip">主标题下方的补充说明,留空使用默认文案。</div>
</el-form-item>
<el-form-item label="Logo">
<div class="upload-row">
<el-upload
class="asset-uploader is-logo"
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/webp,image/svg+xml,image/gif"
:on-change="(file) => handleUpload(file, 'login_logo')"
>
<img
v-if="form.login_logo"
:src="previewUrl(form.login_logo)"
class="asset-image"
alt="登录页 Logo"
/>
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="upload-meta">
<p>显示在登录页左上角,建议透明背景 PNG / SVG,高度不小于 88px。</p>
<el-button v-if="form.login_logo" link type="danger" @click="form.login_logo = ''">
移除 Logo
</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="背景图">
<div class="upload-row">
<el-upload
class="asset-uploader is-bg"
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/webp"
:on-change="(file) => handleUpload(file, 'login_bg')"
>
<img
v-if="form.login_bg"
:src="previewUrl(form.login_bg)"
class="asset-image"
alt="登录页背景图"
/>
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="upload-meta">
<p>铺满登录页左侧,建议浅色系竖版图(≥1200×1600)。深色图片会让标题不清晰。</p>
<el-button v-if="form.login_bg" link type="danger" @click="form.login_bg = ''">
移除背景图
</el-button>
</div>
</div>
</el-form-item>
<el-divider content-position="left">效果预览</el-divider>
<div class="brand-preview">
<div class="preview-left" :class="{ 'has-bg': !!form.login_bg }">
<img v-if="form.login_bg" :src="previewUrl(form.login_bg)" class="preview-bg" alt="" />
<span v-if="form.login_bg" class="preview-veil"></span>
<div class="preview-inner">
<img
v-if="form.login_logo"
:src="previewUrl(form.login_logo)"
class="preview-logo"
alt="Logo"
/>
<div v-else class="preview-logo-fallback">云泽统一认证中心</div>
<p class="preview-title">{{ form.login_title || "一次登录,全平台通行" }}</p>
<p class="preview-sub">
{{ form.login_subtitle || "基于 OIDC / OAuth 2.0 的统一身份底座" }}
</p>
</div>
</div>
<div class="preview-right">
<p class="mock-title">登录</p>
<div class="mock-tabs">
<span class="active">账号密码</span>
<span>短信登录</span>
<span>扫码登录</span>
</div>
<div class="mock-input"></div>
<div class="mock-input"></div>
<div class="mock-btn">登 录</div>
<div class="mock-third">微信登录 · QQ 登录</div>
</div>
</div>
</el-card>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存设置</el-button>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { uploadToWebsiteMaterial } from "@/utils/websiteUpload";
import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings";
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
const formRef = ref();
const loading = ref(false);
const submitting = ref(false);
const form = reactive({
login_title: "",
login_subtitle: "",
login_logo: "",
login_bg: "",
});
// 服务端快照,用于“重置”丢弃未保存的改动
let snapshot = { ...form };
const previewUrl = (url) => {
const value = String(url || "").trim().replace(/\\/g, "/");
if (!value) return "";
if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
};
const loadConfig = async () => {
loading.value = true;
try {
const res = await getLoginBrand();
if (res?.code === 200 && res.data) {
form.login_title = res.data.login_title || "";
form.login_subtitle = res.data.login_subtitle || "";
form.login_logo = res.data.login_logo || "";
form.login_bg = res.data.login_bg || "";
snapshot = { ...form };
} else {
ElMessage.error(res?.msg || "获取登录页配置失败");
}
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "获取登录页配置失败");
} finally {
loading.value = false;
}
};
const handleUpload = async (file, field) => {
const raw = file?.raw;
if (!raw) return;
if (!String(raw.type || "").startsWith("image/")) {
ElMessage.warning("请选择图片文件");
return;
}
if (raw.size > MAX_IMAGE_SIZE) {
ElMessage.warning("图片不能超过 5MB");
return;
}
try {
const res = await uploadToWebsiteMaterial(raw);
const url = res?.data?.url;
if ((res?.code === 200 || res?.code === 201) && url) {
form[field] = String(url).replace(/\\/g, "/");
ElMessage.success("上传成功");
} else {
ElMessage.error(res?.msg || "上传失败");
}
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "上传失败");
}
};
const handleSubmit = async () => {
if (submitting.value) return;
submitting.value = true;
try {
const res = await saveLoginBrand({
login_title: String(form.login_title || "").trim(),
login_subtitle: String(form.login_subtitle || "").trim(),
login_logo: String(form.login_logo || "").trim(),
login_bg: String(form.login_bg || "").trim(),
});
if (res?.code === 200) {
ElMessage.success("保存成功,统一登录页已同步更新");
snapshot = { ...form };
} else {
ElMessage.error(res?.msg || "保存失败");
}
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "保存失败");
} finally {
submitting.value = false;
}
};
const handleReset = () => {
Object.assign(form, snapshot);
ElMessage.info("已恢复到上次保存的配置");
};
onMounted(loadConfig);
</script>
<style lang="less" scoped>
.login-brand-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card {
margin-bottom: 12px;
}
.card-header {
display: flex;
align-items: baseline;
gap: 12px;
font-weight: 600;
font-size: 16px;
}
.card-tip {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.form-tip {
width: 100%;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-top: 4px;
}
.upload-row {
display: flex;
align-items: flex-end;
gap: 16px;
}
.upload-meta {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.7;
p {
margin: 0 0 2px;
}
}
.asset-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
background: #fafbfe;
&:hover {
border-color: #3973ff;
}
}
&.is-logo :deep(.el-upload) {
width: 160px;
height: 72px;
}
&.is-bg :deep(.el-upload) {
width: 132px;
height: 104px;
}
}
.uploader-icon {
font-size: 24px;
color: #8c939d;
}
.asset-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ---------- 效果预览:左 58% 品牌区 + 右 42% 表单区 ---------- */
.brand-preview {
display: flex;
width: 100%;
max-width: 720px;
height: 260px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
}
.preview-left {
position: relative;
flex: 1 1 58%;
display: flex;
align-items: center;
padding: 24px;
overflow: hidden;
background: linear-gradient(150deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
color: #fff;
}
.preview-left.has-bg {
background: #eaf1fb;
color: #1f2b48;
}
.preview-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-veil {
position: absolute;
inset: 0;
background: linear-gradient(100deg, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0.2));
}
.preview-inner {
position: relative;
z-index: 1;
max-width: 100%;
}
.preview-logo {
height: 26px;
max-width: 140px;
object-fit: contain;
margin-bottom: 18px;
}
.preview-logo-fallback {
font-size: 13px;
font-weight: 600;
margin-bottom: 18px;
opacity: 0.9;
}
.preview-title {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.35;
white-space: pre-line;
word-break: break-word;
}
.preview-sub {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.7;
opacity: 0.78;
white-space: pre-line;
word-break: break-word;
}
.preview-right {
flex: 0 0 42%;
background: #fff;
padding: 26px 24px;
}
.mock-title {
margin: 0 0 12px;
font-size: 18px;
font-weight: 700;
color: #1f2b48;
}
.mock-tabs {
display: flex;
gap: 10px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eef2fa;
font-size: 9px;
color: #8a94ad;
.active {
color: #3973ff;
font-weight: 600;
}
}
.mock-input {
height: 24px;
border: 1px solid #e3e9f5;
border-radius: 6px;
margin-bottom: 8px;
background: #fbfcff;
}
.mock-btn {
height: 28px;
margin-top: 14px;
border-radius: 6px;
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #fff;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 2px;
}
.mock-third {
margin-top: 10px;
text-align: center;
font-size: 9px;
color: #a3acbf;
}
</style>
@@ -31,6 +31,11 @@
v-if="activeTab === 'notification'"
/>
</el-tab-pane>
<!-- 登录页设置(统一认证中心 /auth/login) -->
<el-tab-pane label="登录页设置" name="loginBrand">
<loginBrandSettings v-if="activeTab === 'loginBrand'" />
</el-tab-pane>
</el-tabs>
</div>
</div>
@@ -41,6 +46,7 @@ import { ref } from "vue";
import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
import notificationSettings from "./components/notificationSettings.vue";
import loginBrandSettings from "./components/loginBrandSettings.vue";
const activeTab = ref("platform");
</script>
+4 -2
View File
@@ -391,9 +391,11 @@ function cellClass(cell) {
return cell.isFuture ? 'is-future' : 'is-none'
}
// 补写窗口内、尚未写日报的日期标注「补」,点击直接进入补写
// 补写窗口内、往日尚未写日报的日期标注「补」,点击直接进入补写
// 今天属于正常填写,未写时不标「补」
function cellBackfill(cell) {
if (!cell || cell.isFuture || cell.record) return false
if (cell.date === today) return false
return inWindow(cell.date)
}
@@ -403,7 +405,7 @@ function onCalendarCellTap(cell) {
openDetail(cell.record.id)
return
}
if (cellBackfill(cell)) {
if (cell.date === today || cellBackfill(cell)) {
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?date=${cell.date}` })
return
}