更新日报相关功能
This commit is contained in:
1 parent
51fd90fc51
commit
68cdaf38fa
23 files changed
+2313
-1056
No files matched your search
Vendored
+96
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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}(未填写)`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user