fix: 标准标签

This commit is contained in:
2026-03-06 15:45:30 +08:00
parent 13fe5d9124
commit 39246c9483
10 changed files with 3108 additions and 257 deletions

2
.env
View File

@@ -1,5 +1,5 @@
# 标题 # 标题
VITE_APP_TITLE=衣朵云管理系统 VITE_APP_TITLE=Ai 决策通
# 项目本地运行端口号 # 项目本地运行端口号
VITE_PORT=80 VITE_PORT=80

View File

@@ -7,11 +7,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="keywords" name="keywords"
content="衣朵云管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" content="Ai 决策通 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
/> />
<meta <meta
name="description" name="description"
content="衣朵云管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" content="Ai 决策通 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
/> />
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
</head> </head>

View File

@@ -0,0 +1,92 @@
import request from '@/config/axios'
/** 标签同步配置 */
export interface TagConfig {
id?: number
databaseId?: number
cronExpression?: string
/** 是否开启自动同步 */
autoSync?: boolean
lastSyncTime?: string
nextSyncTime?: string
/** 剔除大类(多选,逗号分隔) */
productCategoryIds?: string
/** 剔除仓库(多选,逗号分隔) */
syncWarehouseIds?: string
}
/** 标准标签 */
export interface Tag {
id?: number
name?: string
type?: string
expression?: string
color?: string
sqlScript?: string
/** 已拼接参数的SQL执行时直接使用不展示 */
sqlScriptResolved?: string
useParams?: boolean
params?: string
/** 参数值 JSON如 {"参数1":"28","参数2":"90"} */
paramValues?: string
}
/** 标签同步历史 */
export interface TagSyncHistory {
id?: number
tagConfigId?: number
syncType?: string
syncTime?: string
syncStatus?: string
recordCount?: number
errorMessage?: string
}
/** 统一保存请求(配置+标签) */
export interface TagConfigSaveAllReq {
config?: TagConfig | null
tags?: Tag[]
}
export const TagConfigApi = {
getTagConfigPage: (params: any) =>
request.get({ url: '/ydoyun/tag-config/page', params }),
getTagConfig: (id: number) =>
request.get({ url: '/ydoyun/tag-config/get?id=' + id }),
getTagConfigByDatabaseId: (databaseId: number) =>
request.get({ url: '/ydoyun/tag-config/get-by-database?databaseId=' + databaseId }),
getTagConfigByTenant: () =>
request.get({ url: '/ydoyun/tag-config/get-by-tenant' }),
saveAll: (data: TagConfigSaveAllReq) =>
request.post({ url: '/ydoyun/tag-config/save', data }),
createTagConfig: (data: TagConfig) =>
request.post({ url: '/ydoyun/tag-config/create', data }),
updateTagConfig: (data: TagConfig) =>
request.put({ url: '/ydoyun/tag-config/update', data }),
deleteTagConfig: (id: number) =>
request.delete({ url: '/ydoyun/tag-config/delete?id=' + id }),
manualSync: (tagConfigId: number) =>
request.post({ url: '/ydoyun/tag-config/manual-sync?tagConfigId=' + tagConfigId }),
}
export const TagApi = {
getTagPage: (params: any) =>
request.get({ url: '/ydoyun/tag/page', params }),
getTag: (id: number) =>
request.get({ url: '/ydoyun/tag/get?id=' + id }),
getTagListByType: (type?: string) =>
request.get({ url: '/ydoyun/tag/list-by-type', params: { type } }),
createTag: (data: Tag) =>
request.post({ url: '/ydoyun/tag/create', data }),
updateTag: (data: Tag) =>
request.put({ url: '/ydoyun/tag/update', data }),
deleteTag: (id: number) =>
request.delete({ url: '/ydoyun/tag/delete?id=' + id }),
}
export const TagSyncHistoryApi = {
getTagSyncHistoryPage: (params: any) =>
request.get({ url: '/ydoyun/tag-sync-history/page', params }),
getTagSyncHistory: (id: number) =>
request.get({ url: '/ydoyun/tag-sync-history/get?id=' + id }),
}

View File

@@ -66,10 +66,13 @@ watch(
]" ]"
to="/" to="/"
> >
<img <svg
class="h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]" class="logo-svg h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]"
src="@/assets/imgs/logo.png" viewBox="0 0 100 100"
/> >
<path d="M50 2L2 25.5v49L50 98l48-23.5v-49L50 2zm35.2 65.7L50 88.3 14.8 67.7V32.3L50 11.7l35.2 20.6v35.4z" />
<path d="M38.1 48.9h23.8L50 28.3 38.1 48.9zM50 71.7l11.9-20.6H38.1L50 71.7z" />
</svg>
<div <div
v-if="show" v-if="show"
:class="[ :class="[
@@ -86,3 +89,10 @@ watch(
</router-link> </router-link>
</div> </div>
</template> </template>
<style scoped>
.logo-svg {
fill: var(--logo-title-text-color);
flex-shrink: 0;
}
</style>

View File

@@ -114,7 +114,7 @@ export default {
small: '小' small: '小'
}, },
login: { login: {
welcome: '衣朵云管理系统', welcome: 'Ai 决策通',
message: '开箱即用的管理系统', message: '开箱即用的管理系统',
tenantname: '租户名称', tenantname: '租户名称',
username: '用户名', username: '用户名',

View File

@@ -8,7 +8,10 @@ const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#createWebHistory URL不带# history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#createWebHistory URL不带#
strict: true, strict: true,
routes: remainingRouter as RouteRecordRaw[], routes: remainingRouter as RouteRecordRaw[],
scrollBehavior: () => ({ left: 0, top: 0 }) scrollBehavior: () =>
new Promise((resolve) => {
setTimeout(() => resolve({ left: 0, top: 0 }), 0)
})
}) })
export const resetRouter = (): void => { export const resetRouter = (): void => {

View File

@@ -1,111 +1,393 @@
<template> <template>
<div <div class="login-page">
:class="prefixCls" <!-- 动态背景 -->
class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px" <div class="bg-container">
> <div class="blob blob-1"></div>
<div class="relative mx-auto h-full flex"> <div class="blob blob-2"></div>
<div <div class="grid-base"></div>
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`" <div class="flow-line h-line" style="top: 20%; left: -150px; animation-delay: 0s"></div>
> <div class="flow-line h-line" style="top: 60%; left: -150px; animation-delay: 3s"></div>
<!-- 左上角的 logo + 系统标题 --> <div class="flow-line v-line" style="left: 30%; top: -150px; animation-delay: 1.5s"></div>
<div class="relative flex items-center text-white">
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
</div> </div>
<!-- 左边的背景图 + 欢迎语 -->
<div class="h-[calc(100%-60px)] flex items-center justify-center"> <!-- 右上角主题语言 -->
<TransitionGroup <div class="login-header-actions">
appear
enter-active-class="animate__animated animate__bounceInLeft"
tag="div"
>
<img key="1" alt="" class="w-350px" src="@/assets/svgs/001.png" />
<div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
<div key="3" class="mt-5 text-14px font-normal text-white">
{{ t('login.message') }}
</div>
</TransitionGroup>
</div>
</div>
<div
class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto"
>
<!-- 右上角的主题语言选择 -->
<div
class="flex items-center justify-between at-2xl:justify-end at-xl:justify-end"
style="color: var(--el-text-color-primary);"
>
<div class="flex items-center at-2xl:hidden at-xl:hidden">
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
<span class="text-20px font-bold" >{{ underlineToHump(appStore.getTitle) }}</span>
</div>
<div class="flex items-center justify-end space-x-10px h-48px">
<ThemeSwitch /> <ThemeSwitch />
<LocaleDropdown /> <LocaleDropdown />
</div> </div>
<!-- 主内容区 -->
<div class="container">
<!-- 左侧面板 -->
<div class="left-panel">
<div class="brand-box">
<div class="logo-main">
<svg viewBox="0 0 100 100" class="logo-svg">
<path d="M50 2L2 25.5v49L50 98l48-23.5v-49L50 2zm35.2 65.7L50 88.3 14.8 67.7V32.3L50 11.7l35.2 20.6v35.4z" />
<path d="M38.1 48.9h23.8L50 28.3 38.1 48.9zM50 71.7l11.9-20.6H38.1L50 71.7z" />
</svg>
</div> </div>
<!-- 右边的登录界面 --> <div class="brand-name">Ai 决策通</div>
<Transition appear enter-active-class="animate__animated animate__bounceInRight">
<div
class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
>
<!-- 账号登录 -->
<LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
</div> </div>
</Transition> <h2 class="sub-title">服鞋行业AI决策管理平台</h2>
<p class="desc">
专业的服鞋供应链 AI 决策中心通过实时大数据分析与行业领先的智能算法助力用户实现高效配补货决策及数字化经营管理
</p>
<div class="features">
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2v-4h2v4zm4 0h-2V7h2v10zm-8 0H8v-7h2v7z"/></svg>
</div> </div>
AI智能配补货
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 17.08l1.5 1.41z"/></svg>
</div>
AI报表平台
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M22 2H2v20h20V2zM10 7h4v2h-4V7zm8 10H6v-2h12v2zm0-4H6v-2h12v2zm0-4h-2V7h2v2z"/></svg>
</div>
AI管理工具包
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/></svg>
</div>
AI智能标签
</div>
</div>
</div>
<!-- 右侧登录面板 -->
<div class="right-panel">
<div class="login-card">
<LoginForm />
</div>
</div>
</div>
<div class="footer">
长沙决策通科技有限公司 &copy; 2026 版权所有
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup>
import { underlineToHump } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign' <script lang="ts" setup>
import { useAppStore } from '@/store/modules/app'
import { ThemeSwitch } from '@/layout/components/ThemeSwitch' import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
import { LocaleDropdown } from '@/layout/components/LocaleDropdown' import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import { LoginForm } from './components'
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue, ForgetPasswordForm } from './components'
defineOptions({ name: 'Login' }) defineOptions({ name: 'Login' })
const { t } = useI18n()
const appStore = useAppStore()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('login')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
$prefix-cls: #{$namespace}-login; .login-page {
--primary-blue: #004fb0;
--btn-grad: linear-gradient(180deg, #8cbaff 0%, #3e81e0 100%);
--glass: rgba(255, 255, 255, 0.35);
--grid-size: 70px;
.#{$prefix-cls} { position: relative;
overflow: auto; min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
&__left { .bg-container {
&::before { position: fixed;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
.grid-base {
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0; background-image: linear-gradient(rgba(0, 79, 176, 0.08) 1.5px, transparent 1.5px),
z-index: -1; linear-gradient(90deg, rgba(0, 79, 176, 0.08) 1.5px, transparent 1.5px);
background-size: var(--grid-size) var(--grid-size);
}
.blob {
position: absolute;
filter: blur(80px);
opacity: 0.5;
border-radius: 50%;
z-index: 1;
}
.blob-1 {
width: 700px;
height: 700px;
background: #9ec9ff;
top: -15%;
left: -5%;
}
.blob-2 {
width: 600px;
height: 600px;
background: #7fb4ff;
bottom: -15%;
right: -5%;
}
.flow-line {
position: absolute;
background: linear-gradient(90deg, transparent, #4388e4, transparent);
z-index: 2;
border-radius: 2px;
opacity: 0.6;
}
.h-line {
height: 1.5px;
width: 150px;
animation: h-move 7s linear infinite;
}
.v-line {
width: 1.5px;
height: 150px;
background: linear-gradient(180deg, transparent, #4388e4, transparent);
animation: v-move 9s linear infinite;
}
@keyframes h-move {
0% {
transform: translateX(0);
opacity: 0;
}
10%,
90% {
opacity: 0.8;
}
100% {
transform: translateX(100vw);
opacity: 0;
}
}
@keyframes v-move {
0% {
transform: translateY(0);
opacity: 0;
}
10%,
90% {
opacity: 0.8;
}
100% {
transform: translateY(100vh);
opacity: 0;
}
}
.login-header-actions {
position: fixed;
top: 20px;
right: 24px;
z-index: 100;
display: flex;
align-items: center;
gap: 12px;
}
.container {
position: relative;
z-index: 10;
width: 100%; width: 100%;
height: 100%; max-width: 1300px;
background-image: url('@/assets/svgs/login-bg.svg'); margin: 0 auto;
background-position: center; padding: 80px 40px 100px;
background-repeat: no-repeat; display: flex;
content: ''; justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.left-panel {
flex: 1.3;
padding-top: 10px;
}
.brand-box {
display: flex;
align-items: center;
margin-bottom: 25px;
}
.logo-main {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #0052d4, #4364f7);
border-radius: 14px;
margin-right: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 20px rgba(0, 82, 212, 0.2);
}
.logo-svg {
width: 40px;
height: 40px;
fill: white;
}
.brand-name {
font-size: 42px;
font-weight: 900;
color: var(--primary-blue);
letter-spacing: -1px;
}
.sub-title {
font-size: 24px;
font-weight: 600;
color: #222;
margin-bottom: 25px;
padding-left: 5px;
}
.desc {
color: #5c6e82;
font-size: 15px;
max-width: 500px;
line-height: 1.8;
margin-bottom: 50px;
padding-left: 5px;
}
.features {
display: grid;
grid-template-columns: repeat(2, 260px);
gap: 20px;
padding-left: 5px;
}
.feature-card {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(10px);
border: 1px solid white;
padding: 18px 22px;
border-radius: 15px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
transition: 0.3s;
color: #333;
}
.feature-card:hover {
transform: translateY(-5px);
background: #fff;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
}
.icon-box {
width: 36px;
height: 36px;
background: #2a5fb2;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
.icon-box svg {
width: 20px;
height: 20px;
fill: white;
}
.right-panel {
flex: 0.7;
display: flex;
justify-content: flex-end;
}
.login-card {
width: 420px;
background: var(--glass);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 40px;
padding: 50px 45px;
box-shadow: 0 40px 80px rgba(0, 79, 176, 0.12);
}
.footer {
position: fixed;
bottom: 30px;
left: 0;
right: 0;
text-align: center;
font-size: 13px;
color: #94a3b8;
z-index: 10;
}
@media (max-width: 1200px) {
.container {
flex-direction: column;
align-items: center;
} }
.left-panel {
max-width: 600px;
}
.right-panel {
width: 100%;
justify-content: center;
}
}
@media (max-width: 768px) {
.brand-name {
font-size: 32px;
}
.sub-title {
font-size: 20px;
}
.features {
grid-template-columns: 1fr;
}
.login-card {
width: 100%;
max-width: 420px;
padding: 40px 30px;
} }
} }
</style> </style>
<!-- 暗色主题下字体颜色跟随反转 -->
<style lang="scss"> <style lang="scss">
.dark .login-form { .dark .login-page {
.el-divider__text { .brand-name {
background-color: var(--login-bg-color); color: var(--el-color-primary-light-3);
} }
.sub-title {
.el-card { color: var(--el-text-color-primary);
background-color: var(--login-bg-color); }
.desc {
color: var(--el-text-color-regular);
}
.feature-card {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
&:hover {
background: var(--el-bg-color);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
}
.login-card {
background: var(--el-bg-color-overlay);
border-color: var(--el-border-color);
}
.footer {
color: var(--el-text-color-placeholder);
} }
} }
</style> </style>

View File

@@ -4,71 +4,68 @@
ref="formLogin" ref="formLogin"
:model="loginData.loginForm" :model="loginData.loginForm"
:rules="LoginRules" :rules="LoginRules"
class="login-form" class="login-form-styled"
label-position="top" label-position="top"
label-width="120px" label-width="0"
size="large" size="large"
> >
<el-row class="mx-[-10px]"> <div class="login-header">
<el-col :span="24" class="px-10px"> <div class="welcome-text">欢迎登录</div>
<el-form-item> </div>
<LoginFormTitle class="w-full" />
</el-form-item> <!-- 租户名 -->
</el-col> <div v-if="loginData.tenantEnable === 'true'" class="input-group">
<el-col :span="24" class="px-10px"> <el-form-item prop="tenantName">
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
<el-input <el-input
v-model="loginData.loginForm.tenantName" v-model="loginData.loginForm.tenantName"
:placeholder="t('login.tenantNamePlaceholder')" :placeholder="t('login.tenantNamePlaceholder')"
:prefix-icon="iconHouse" class="styled-input"
link
type="primary"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px">
<!-- 用户名 -->
<div class="input-group">
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
v-model="loginData.loginForm.username" v-model="loginData.loginForm.username"
:placeholder="t('login.usernamePlaceholder')" :placeholder="t('login.usernamePlaceholder')"
:prefix-icon="iconAvatar" class="styled-input"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px">
<!-- 密码 -->
<div class="input-group">
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input
v-model="loginData.loginForm.password" v-model="loginData.loginForm.password"
:placeholder="t('login.passwordPlaceholder')" :placeholder="t('login.passwordPlaceholder')"
:prefix-icon="iconLock" class="styled-input"
show-password show-password
type="password" type="password"
@keyup.enter="getCode()" @keyup.enter="getCode()"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px mt-[-20px] mb-[-20px]">
<el-form-item> <!-- 记住我 -->
<el-row justify="space-between" style="width: 100%"> <div class="links-row">
<el-col :span="6">
<el-checkbox v-model="loginData.loginForm.rememberMe"> <el-checkbox v-model="loginData.loginForm.rememberMe">
{{ t('login.remember') }} {{ t('login.remember') }}
</el-checkbox> </el-checkbox>
</el-col> </div>
</el-row>
</el-form-item> <!-- 登录按钮 -->
</el-col> <button
<el-col :span="24" class="px-10px"> type="button"
<el-form-item> class="login-btn"
<XButton :disabled="loginLoading"
:loading="loginLoading"
:title="t('login.login')"
class="w-full"
type="primary"
@click="getCode()" @click="getCode()"
/> >
</el-form-item> {{ loginLoading ? '登录中...' : t('login.login') }}
</el-col> </button>
<Verify <Verify
v-if="loginData.captchaEnable === 'true'" v-if="loginData.captchaEnable === 'true'"
ref="verify" ref="verify"
@@ -77,16 +74,14 @@
mode="pop" mode="pop"
@success="handleLogin" @success="handleLogin"
/> />
</el-row>
</el-form> </el-form>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ElLoading } from 'element-plus' import { ElLoading } from 'element-plus'
import LoginFormTitle from './LoginFormTitle.vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { useIcon } from '@/hooks/web/useIcon' import { required } from '@/utils/formRules'
import * as authUtil from '@/utils/auth' import * as authUtil from '@/utils/auth'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import * as LoginApi from '@/api/login' import * as LoginApi from '@/api/login'
@@ -96,28 +91,27 @@ defineOptions({ name: 'LoginForm' })
const { t } = useI18n() const { t } = useI18n()
const message = useMessage() const message = useMessage()
const iconHouse = useIcon({ icon: 'ep:house' })
const iconAvatar = useIcon({ icon: 'ep:avatar' })
const iconLock = useIcon({ icon: 'ep:lock' })
const formLogin = ref() const formLogin = ref()
const { validForm } = useFormValid(formLogin) const { validForm } = useFormValid(formLogin)
const { setLoginState, getLoginState } = useLoginState() const { getLoginState } = useLoginState()
const { currentRoute, push } = useRouter() const { currentRoute, push } = useRouter()
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
const redirect = ref<string>('') const redirect = ref<string>('')
const loginLoading = ref(false) const loginLoading = ref(false)
const verify = ref() const verify = ref()
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码 const captchaType = ref('blockPuzzle')
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
const LoginRules = { const LoginRules: Record<string, any> = {
tenantName: [required],
username: [required], username: [required],
password: [required] password: [required]
} }
if (import.meta.env.VITE_APP_TENANT_ENABLE === 'true') {
LoginRules.tenantName = [required]
}
const loginData = reactive({ const loginData = reactive({
isShowPassword: false,
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
loginForm: { loginForm: {
@@ -125,49 +119,38 @@ const loginData = reactive({
username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '', username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '', password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
captchaVerification: '', captchaVerification: '',
rememberMe: true // 默认记录我。如果不需要,可手动修改 rememberMe: true
} }
}) })
const socialList = [
{ icon: 'ant-design:wechat-filled', type: 30 },
{ icon: 'ant-design:dingtalk-circle-filled', type: 20 },
{ icon: 'ant-design:github-filled', type: 0 },
{ icon: 'ant-design:alipay-circle-filled', type: 0 }
]
// 获取验证码
const getCode = async () => { const getCode = async () => {
// 情况一,未开启:则直接登录
if (loginData.captchaEnable === 'false') { if (loginData.captchaEnable === 'false') {
await handleLogin({}) await handleLogin({})
} else { } else {
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 verify.value?.show()
// 弹出验证码
verify.value.show()
} }
} }
// 获取租户 ID
const getTenantId = async () => { const getTenantId = async () => {
if (loginData.tenantEnable === 'true') { if (loginData.tenantEnable === 'true') {
const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
authUtil.setTenantId(res) authUtil.setTenantId(res)
} }
} }
// 记住我
const getLoginFormCache = () => { const getLoginFormCache = () => {
const loginForm = authUtil.getLoginForm() const loginForm = authUtil.getLoginForm()
if (loginForm) { if (loginForm) {
loginData.loginForm = { loginData.loginForm = {
...loginData.loginForm, ...loginData.loginForm,
username: loginForm.username ? loginForm.username : loginData.loginForm.username, username: loginForm.username ?? loginData.loginForm.username,
password: loginForm.password ? loginForm.password : loginData.loginForm.password, password: loginForm.password ?? loginData.loginForm.password,
rememberMe: loginForm.rememberMe, rememberMe: loginForm.rememberMe ?? true,
tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName tenantName: loginForm.tenantName ?? loginData.loginForm.tenantName
} }
} }
} }
// 根据域名,获得租户信息
const getTenantByWebsite = async () => { const getTenantByWebsite = async () => {
if (loginData.tenantEnable === 'true') { if (loginData.tenantEnable === 'true') {
const website = location.host const website = location.host
@@ -178,8 +161,9 @@ const getTenantByWebsite = async () => {
} }
} }
} }
const loading = ref() // ElLoading.service 返回的实例
// 登录 let loading: any = null
const handleLogin = async (params: any) => { const handleLogin = async (params: any) => {
loginLoading.value = true loginLoading.value = true
try { try {
@@ -188,19 +172,19 @@ const handleLogin = async (params: any) => {
if (!data) { if (!data) {
return return
} }
const loginDataLoginForm = { ...loginData.loginForm } const loginFormData = { ...loginData.loginForm }
loginDataLoginForm.captchaVerification = params.captchaVerification loginFormData.captchaVerification = params.captchaVerification
const res = await LoginApi.login(loginDataLoginForm) const res = await LoginApi.login(loginFormData)
if (!res) { if (!res) {
return return
} }
loading.value = ElLoading.service({ loading = ElLoading.service({
lock: true, lock: true,
text: '正在加载系统中...', text: '正在加载系统中...',
background: 'rgba(0, 0, 0, 0.7)' background: 'rgba(0, 0, 0, 0.7)'
}) })
if (loginDataLoginForm.rememberMe) { if (loginFormData.rememberMe) {
authUtil.setLoginForm(loginDataLoginForm) authUtil.setLoginForm(loginFormData)
} else { } else {
authUtil.removeLoginForm() authUtil.removeLoginForm()
} }
@@ -208,7 +192,6 @@ const handleLogin = async (params: any) => {
if (!redirect.value) { if (!redirect.value) {
redirect.value = '/' redirect.value = '/'
} }
// 判断是否为SSO登录
if (redirect.value.indexOf('sso') !== -1) { if (redirect.value.indexOf('sso') !== -1) {
window.location.href = window.location.href.replace('/login?redirect=', '') window.location.href = window.location.href.replace('/login?redirect=', '')
} else { } else {
@@ -216,54 +199,18 @@ const handleLogin = async (params: any) => {
} }
} finally { } finally {
loginLoading.value = false loginLoading.value = false
loading.value.close() loading?.close?.()
} }
} }
// 社交登录
const doSocialLogin = async (type: number) => {
if (type === 0) {
message.error('此方式未配置')
} else {
loginLoading.value = true
if (loginData.tenantEnable === 'true') {
// 尝试先通过 tenantName 获取租户
await getTenantId()
// 如果获取不到,则需要弹出提示,进行处理
if (!authUtil.getTenantId()) {
try {
const data = await message.prompt('请输入租户名称', t('common.reminder'))
if (data?.action !== 'confirm') throw 'cancel'
const res = await LoginApi.getTenantIdByName(data.value)
authUtil.setTenantId(res)
} catch (error) {
if (error === 'cancel') return
} finally {
loginLoading.value = false
}
}
}
// 计算 redirectUri
// 注意: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。
// 配合 social-login.vue#getUrlValue() 使用
const redirectUri =
location.origin +
'/social-login?' +
encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`)
// 进行跳转
window.location.href = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
}
}
watch( watch(
() => currentRoute.value, () => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => { (route: RouteLocationNormalizedLoaded) => {
redirect.value = route?.query?.redirect as string redirect.value = route?.query?.redirect as string
}, },
{ { immediate: true }
immediate: true
}
) )
onMounted(() => { onMounted(() => {
getLoginFormCache() getLoginFormCache()
getTenantByWebsite() getTenantByWebsite()
@@ -271,23 +218,119 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.anticon) { .login-form-styled {
&:hover { :deep(.el-form-item) {
color: var(--el-color-primary) !important; margin-bottom: 0;
}
:deep(.el-form-item__error) {
padding-top: 4px;
} }
} }
.login-code { .login-header {
float: right; margin-bottom: 40px;
width: 100%; }
height: 38px;
img { .welcome-text {
font-size: 26px;
font-weight: bold;
color: #222;
}
.input-group {
margin-bottom: 20px;
:deep(.styled-input) {
.el-input__wrapper {
width: 100%; width: 100%;
height: auto; padding: 18px 22px;
max-width: 100px; border-radius: 15px;
vertical-align: middle; border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.85);
font-size: 16px;
transition: 0.3s;
box-shadow: none;
}
.el-input__wrapper:hover {
border-color: rgba(0, 79, 176, 0.3);
}
.el-input__wrapper.is-focus {
border-color: #004fb0;
background: #fff;
}
.el-input__inner,
input {
font-size: 16px;
color: #333 !important;
-webkit-text-fill-color: #333 !important;
}
}
}
.links-row {
display: flex;
align-items: center;
font-size: 14px;
margin: 15px 0 40px;
:deep(.el-checkbox__label) {
color: #6e7c8d;
}
}
.login-btn {
width: 100%;
padding: 18px;
background: linear-gradient(180deg, #8cbaff 0%, #3e81e0 100%);
border: none;
border-radius: 35px;
color: white;
font-size: 20px;
font-weight: bold;
cursor: pointer; cursor: pointer;
box-shadow: 0 12px 25px rgba(62, 129, 224, 0.35);
transition: 0.3s;
}
.login-btn:hover:not(:disabled) {
transform: scale(1.02);
filter: brightness(1.1);
}
.login-btn:disabled {
opacity: 0.8;
cursor: not-allowed;
}
</style>
<!-- 暗色主题下登录表单字体颜色跟随反转 -->
<style lang="scss">
.dark .login-form-styled {
.welcome-text {
color: var(--el-text-color-primary);
}
.links-row :deep(.el-checkbox__label) {
color: var(--el-text-color-regular);
}
.input-group :deep(.el-input__wrapper) {
background: var(--el-fill-color);
border-color: var(--el-border-color);
}
/* 已输入文字颜色 - 确保暗色下清晰可见 */
.input-group :deep(.el-input__inner),
.input-group :deep(input) {
color: var(--el-text-color-primary) !important;
-webkit-text-fill-color: var(--el-text-color-primary) !important;
}
.input-group :deep(input::placeholder) {
color: var(--el-text-color-placeholder);
}
.input-group :deep(.el-input__wrapper.is-focus) {
background: var(--el-bg-color);
border-color: var(--el-color-primary);
}
.input-group :deep(.el-input__wrapper.is-focus .el-input__inner),
.input-group :deep(.el-input__wrapper.is-focus input) {
color: var(--el-text-color-primary) !important;
-webkit-text-fill-color: var(--el-text-color-primary) !important;
} }
} }
</style> </style>

View File

@@ -0,0 +1,192 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="标签类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类型" class="!w-full">
<el-option label="产品基础标签 (product)" value="product" />
<el-option label="门店基础标签 (store)" value="store" />
<el-option label="供应商基础标签 (supplier)" value="supplier" />
<el-option label="会员基础标签 (member)" value="member" />
</el-select>
</el-form-item>
<el-form-item label="标签表达式" prop="expression">
<el-input v-model="formData.expression" type="textarea" placeholder="请输入标签表达式" />
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="color-selector-row">
<div class="dot-group">
<span
v-for="c in presetColors"
:key="c"
class="color-dot color-dot-clickable"
:class="{ 'color-dot-active': formData.color === c }"
:style="{ backgroundColor: c }"
:title="'预设色 ' + c"
@click="formData.color = c"
></span>
</div>
<el-color-picker v-model="formData.color" :predefine="presetColors" size="default" />
</div>
</el-form-item>
<el-form-item label="执行SQL脚本" prop="sqlScript">
<el-input
v-model="formData.sqlScript"
type="textarea"
:rows="10"
placeholder="请输入标签同步时执行的SQL脚本支持多行"
class="sql-script-area"
/>
<div class="form-item-hint">该SQL将用于标签计算与同步请确保语法正确</div>
</el-form-item>
<el-form-item label="是否代入参数" prop="useParams">
<el-radio-group v-model="formData.useParams">
<el-radio :value="true"></el-radio>
<el-radio :value="false"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="参数列表" prop="params">
<el-input v-model="formData.params" placeholder="逗号分隔param1,param2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { TagApi } from '@/api/ydoyun/tagconfig'
import type { Tag } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'TagForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref<Tag>({
id: undefined,
name: undefined,
type: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: false,
params: undefined,
})
const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }],
type: [{ required: true, message: '请选择标签类型', trigger: 'change' }],
sqlScript: [{ required: true, message: '执行SQL脚本不能为空', trigger: 'blur' }],
useParams: [{ required: true, message: '请选择是否代入参数', trigger: 'change' }],
})
const formRef = ref()
/** 4 个预设颜色(蓝、红、黄、绿) */
const presetColors = ['#2f54eb', '#f5222d', '#fa8c16', '#52c41a']
const open = async (type: string, id?: number, initialType?: string) => {
dialogVisible.value = true
dialogTitle.value = type === 'create' ? '新增标签' : '编辑标签'
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
const res = await TagApi.getTag(id)
formData.value = (res as any)?.data || {}
} finally {
formLoading.value = false
}
} else if (type === 'create' && initialType) {
formData.value.type = initialType
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = formData.value
if (formType.value === 'create') {
await TagApi.createTag(data)
message.success(t('common.createSuccess'))
} else {
await TagApi.updateTag(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
type: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: false,
params: undefined,
}
formRef.value?.resetFields()
}
</script>
<style scoped>
.color-selector-row {
display: flex;
align-items: center;
gap: 12px;
}
.dot-group {
display: flex;
gap: 8px;
}
.color-dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d9d9d9;
flex-shrink: 0;
}
.color-dot-clickable {
cursor: pointer;
transition: transform 0.15s;
}
.color-dot-clickable:hover {
transform: scale(1.1);
}
.color-dot-active {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--el-color-primary);
}
.sql-script-area :deep(textarea) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
}
.form-item-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

File diff suppressed because it is too large Load Diff