mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-16 22:51:14 +00:00
chore: 初始化项目基础结构与配置
添加了nvm配置、静态资源、项目配置文件、工具类、api模块、状态管理、国际化、路由配置等基础代码,搭建了uniapp前端项目的初始框架。
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwiperItem } from '@wot-ui/ui/components/wd-swiper/types'
|
||||
import type { DashboardStats, LoginTrendItem } from '@/api/module_monitor/dashboard'
|
||||
import type { NoticeItem } from '@/api/module_system/notice'
|
||||
import { onPullDownRefresh, onReady, onShow } from '@dcloudio/uni-app'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LegacyGridContainLabel } from 'echarts/features'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DashboardAPI } from '@/api/module_monitor/dashboard'
|
||||
import { NoticeAPI } from '@/api/module_system/notice'
|
||||
import { TicketAPI } from '@/api/module_system/ticket'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
useShare(() => ({
|
||||
title: t('index.shareTitle', { greeting: t(`index.${getGreeting()}`), name: userStore.userInfo?.name || t('common.admin') }),
|
||||
path: '/pages/index/index',
|
||||
}))
|
||||
|
||||
echarts.use([
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
LegacyGridContainLabel,
|
||||
LineChart,
|
||||
CanvasRenderer,
|
||||
])
|
||||
|
||||
definePage({
|
||||
name: 'home',
|
||||
layout: 'tabbar',
|
||||
style: { navigationBarTitleText: '工作台概览' },
|
||||
})
|
||||
useI18nNavTitle('index.navTitle')
|
||||
|
||||
let _router: ReturnType<typeof useRouter> | null = null
|
||||
function getRouter() {
|
||||
if (!_router) {
|
||||
try {
|
||||
_router = useRouter()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return _router
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboardStats = ref<DashboardStats | null>(null)
|
||||
const pendingTickets = ref(0)
|
||||
const recentNotices = ref<NoticeItem[]>([])
|
||||
|
||||
/** 最新公告标题(通知栏展示,无公告则隐藏通知栏) */
|
||||
const latestNotice = computed(() => recentNotices.value[0]?.notice_title || t('index.welcomeDefault'))
|
||||
|
||||
/** 页面滚动距离(供 wd-backtop 判断显示) */
|
||||
const scrollTop = ref(0)
|
||||
onPageScroll((e) => {
|
||||
scrollTop.value = e.scrollTop
|
||||
})
|
||||
|
||||
const NAV_LIST = [
|
||||
{ icon: 'notification', titleKey: 'common.nav.notices', name: 'work-notices', color: '#10B981' },
|
||||
{ icon: 'message', titleKey: 'common.nav.tickets', name: 'work-tickets', color: '#F59E0B' },
|
||||
{ icon: 'interaction', titleKey: 'common.nav.aiChat', name: 'work-chat', color: '#06B6D4' },
|
||||
{ icon: 'robot', titleKey: 'common.nav.aiModels', name: 'work-ai-models', color: '#8B5CF6' },
|
||||
]
|
||||
|
||||
/** 轮播 Banner 条目 */
|
||||
interface BannerItem extends SwiperItem {
|
||||
key: string
|
||||
tag: string
|
||||
cls: string
|
||||
title: string
|
||||
subtitle: string
|
||||
desc: string
|
||||
cta: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部轮播 Banner(轮播图风格首页核心):
|
||||
* 1. 今日问候 2. 待办工单 3. 数据概览
|
||||
*/
|
||||
const banners = computed<BannerItem[]>(() => [
|
||||
{
|
||||
key: 'greet',
|
||||
tag: t('index.today'),
|
||||
cls: 'banner-slide--greet',
|
||||
title: `${t(`index.${getGreeting()}`)},${userStore.userInfo?.name || t('common.admin')}`,
|
||||
subtitle: getDateString(),
|
||||
desc: t('index.welcomeBack'),
|
||||
cta: t('index.dataTitle'),
|
||||
onClick: () => scrollToMonitor(),
|
||||
},
|
||||
{
|
||||
key: 'ticket',
|
||||
tag: t('index.todo'),
|
||||
cls: pendingTickets.value > 0 ? 'banner-slide--ticket' : 'banner-slide--ticket-empty',
|
||||
title: pendingTickets.value > 0 ? t('index.pendingCount', { count: pendingTickets.value }) : t('index.allDone'),
|
||||
subtitle: pendingTickets.value > 0 ? t('index.todoSubtitle') : t('index.doneSubtitle'),
|
||||
desc: t('index.ticketDesc'),
|
||||
cta: pendingTickets.value > 0 ? t('index.goHandle') : t('index.viewTickets'),
|
||||
onClick: () => navigateTo('work-tickets'),
|
||||
},
|
||||
{
|
||||
key: 'stats',
|
||||
tag: t('index.data'),
|
||||
cls: 'banner-slide--stats',
|
||||
title: t('index.dataTitle'),
|
||||
subtitle: t('index.dataSubtitle'),
|
||||
desc: t('index.dataDesc', {
|
||||
total: dashboardStats.value?.total_users ?? '-',
|
||||
online: dashboardStats.value?.online_users ?? '-',
|
||||
today: dashboardStats.value?.today_login_count ?? '-',
|
||||
}),
|
||||
cta: t('index.viewDetail'),
|
||||
onClick: () => scrollToMonitor(),
|
||||
},
|
||||
])
|
||||
|
||||
function navigateTo(name: string) {
|
||||
const r = getRouter()
|
||||
if (r)
|
||||
r.push({ name })
|
||||
}
|
||||
|
||||
// ===== 系统监控(原数据概览 dashboard 合并) =====
|
||||
|
||||
/** 近7天登录趋势(后端 monitor/online/stats 返回,含今日) */
|
||||
const loginTrend = ref<LoginTrendItem[]>([])
|
||||
|
||||
/** 登录趋势折线图(真实数据:登录次数 / 独立访客 / 新增用户) */
|
||||
const loginTrendOption = computed(() => {
|
||||
const days = loginTrend.value.length > 0
|
||||
? loginTrend.value.map(i => i.day.slice(5)) // 仅显示 MM-DD
|
||||
: []
|
||||
const logins = loginTrend.value.map(i => i.logins)
|
||||
const uniques = loginTrend.value.map(i => i.unique_users)
|
||||
const news = loginTrend.value.map(i => i.new_users)
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 8,
|
||||
textStyle: { fontSize: 10, color: '#909399' },
|
||||
data: [t('index.loginCount'), t('index.visitor'), t('index.newUser')],
|
||||
},
|
||||
grid: { left: 8, right: 8, top: 34, bottom: 0, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: days,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#999' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#999' },
|
||||
splitLine: { lineStyle: { color: 'rgba(128, 128, 128, 0.12)', type: 'dashed' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('index.loginCount'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: logins,
|
||||
lineStyle: { width: 3, color: '#4F8CFF' },
|
||||
itemStyle: { color: '#4F8CFF' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(79, 140, 255, 0.28)' },
|
||||
{ offset: 1, color: 'rgba(79, 140, 255, 0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('index.visitor'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: uniques,
|
||||
lineStyle: { width: 2.5, color: '#10B981' },
|
||||
itemStyle: { color: '#10B981' },
|
||||
},
|
||||
{
|
||||
name: t('index.newUser'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: news,
|
||||
lineStyle: { width: 2.5, color: '#F59E0B' },
|
||||
itemStyle: { color: '#F59E0B' },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/** 滚动定位到系统监控区块(dashboard 合并后无独立页面,Banner 点击改为页内定位) */
|
||||
function scrollToMonitor() {
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#monitor-section').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
const rect = res[0] as UniApp.NodeInfo | null
|
||||
if (rect && typeof rect.top === 'number') {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: Math.max(rect.top + scrollTop.value - 12, 0),
|
||||
duration: 300,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [statsRes, ticketRes] = await Promise.allSettled([
|
||||
DashboardAPI.getStats(),
|
||||
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '0' }),
|
||||
])
|
||||
if (statsRes.status === 'fulfilled') {
|
||||
dashboardStats.value = statsRes.value
|
||||
loginTrend.value = statsRes.value.login_trend || []
|
||||
}
|
||||
if (ticketRes.status === 'fulfilled')
|
||||
pendingTickets.value = ticketRes.value.total || 0
|
||||
}
|
||||
catch { /* silent */ }
|
||||
finally {
|
||||
loading.value = false
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotices() {
|
||||
try {
|
||||
const res = await NoticeAPI.getPage({ page_no: 1, page_size: 3, status: 1 })
|
||||
recentNotices.value = res.list || []
|
||||
}
|
||||
catch { /* silent */ }
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
Promise.all([loadData(), loadNotices()])
|
||||
})
|
||||
|
||||
onReady(() => {
|
||||
Promise.all([loadData(), loadNotices()])
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0 && pages[pages.length - 1].route === 'pages/index/index')
|
||||
uni.$emit('updateTabbar', 'index')
|
||||
})
|
||||
|
||||
function getDateString() {
|
||||
const now = new Date()
|
||||
const weekDay = t(`index.weekDay.${now.getDay()}`)
|
||||
const y = now.getFullYear()
|
||||
const m = now.getMonth() + 1
|
||||
const d = now.getDate()
|
||||
return locale.value.startsWith('zh')
|
||||
? `${y}${t('index.year')}${m}${t('index.month')}${d}${t('index.day')} ${t('index.week')}${weekDay}`
|
||||
: `${weekDay}, ${m}/${d}/${y}`
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6)
|
||||
return 'night'
|
||||
if (hour < 9)
|
||||
return 'morning'
|
||||
if (hour < 12)
|
||||
return 'forenoon'
|
||||
if (hour < 14)
|
||||
return 'noon'
|
||||
if (hour < 18)
|
||||
return 'afternoon'
|
||||
return 'evening'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page-wraper">
|
||||
<!-- 顶部通知栏:接入最新一条公告(无公告自动隐藏),点击进入公告列表 -->
|
||||
<wd-notice-bar
|
||||
:text="latestNotice"
|
||||
closable
|
||||
type="info"
|
||||
prefix="notification"
|
||||
custom-class="home-notice"
|
||||
class="mb-3"
|
||||
@click="navigateTo('work-notices')"
|
||||
/>
|
||||
|
||||
<!-- 轮播 Banner -->
|
||||
<view class="mx-3 mb-3">
|
||||
<wd-swiper
|
||||
:list="banners"
|
||||
height="120"
|
||||
radius="14"
|
||||
:interval="4500"
|
||||
:autoplay="true"
|
||||
:loop="true"
|
||||
>
|
||||
<template #default="{ index }">
|
||||
<view class="banner-slide" :class="banners[index].cls" @click="banners[index].onClick">
|
||||
<view class="banner-slide__body">
|
||||
<view class="banner-slide__tag">
|
||||
<wd-tag size="small" round bg-color="rgba(255, 255, 255, 0.22)" color="#FFFFFF">
|
||||
{{ banners[index].tag }}
|
||||
</wd-tag>
|
||||
</view>
|
||||
<view class="banner-slide__title">
|
||||
{{ banners[index].title }}
|
||||
</view>
|
||||
<view class="banner-slide__subtitle">
|
||||
{{ banners[index].subtitle }}
|
||||
</view>
|
||||
<view class="banner-slide__desc">
|
||||
{{ banners[index].desc }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="banner-slide__cta">
|
||||
<text class="banner-slide__cta-text">
|
||||
{{ banners[index].cta }}
|
||||
</text>
|
||||
<text class="banner-slide__cta-arrow">
|
||||
›
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<template #indicator="{ current, total }">
|
||||
<view class="banner-dots">
|
||||
<view
|
||||
v-for="i in total"
|
||||
:key="i"
|
||||
class="banner-dots__dot"
|
||||
:class="{ 'is-active': i === current + 1 }"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</wd-swiper>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="mx-3 mb-3 rounded-2 p-2 wot-bg-filled-oppo">
|
||||
<wd-grid :column="4" :border="false" clickable>
|
||||
<wd-grid-item
|
||||
v-for="item in NAV_LIST"
|
||||
:key="item.name"
|
||||
@click="navigateTo(item.name)"
|
||||
>
|
||||
<view class="w-full flex flex-col items-center">
|
||||
<view
|
||||
class="h-11 w-11 flex items-center justify-center rounded-xl"
|
||||
:style="{ backgroundColor: `${item.color}1a` }"
|
||||
>
|
||||
<wd-icon :name="item.icon" size="20px" :color="item.color" />
|
||||
</view>
|
||||
<view class="mt-1 w-full text-center text-2.5 wot-text-text-secondary">
|
||||
{{ t(item.titleKey) }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-grid-item>
|
||||
</wd-grid>
|
||||
</view>
|
||||
|
||||
<!-- 运营概览 -->
|
||||
<view class="mb-2 mt-4 flex items-center gap-2 px-3">
|
||||
<view class="h-3.5 w-1 rounded-full" style="background-color: var(--primary-color, #4F8CFF);" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t('index.opsOverview') }}
|
||||
</text>
|
||||
</view>
|
||||
<SkeletonPage v-if="loading && !dashboardStats" :rows="3" />
|
||||
<view v-else class="mx-3">
|
||||
<wd-row :gutter="12">
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo">
|
||||
<wd-count-to v-if="dashboardStats?.total_users != null" :end-val="dashboardStats.total_users" type="primary" custom-class="stat-count" />
|
||||
<view v-else class="text-5 font-bold wot-text-text-main">
|
||||
-
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('index.registered') }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-2.5 wot-text-text-auxiliary">
|
||||
{{ t('index.weekNew', { count: dashboardStats?.week_user_created ?? 0 }) }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo">
|
||||
<wd-count-to v-if="dashboardStats?.online_users != null" :end-val="dashboardStats.online_users" type="success" custom-class="stat-count" />
|
||||
<view v-else class="text-5 font-bold wot-text-text-main">
|
||||
-
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('index.online') }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-2.5 wot-text-text-auxiliary">
|
||||
{{ t('index.currentOnline') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo">
|
||||
<wd-count-to v-if="dashboardStats?.today_login_count != null" :end-val="dashboardStats.today_login_count" type="warning" custom-class="stat-count" />
|
||||
<view v-else class="text-5 font-bold wot-text-text-main">
|
||||
-
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('index.todayLogin') }}
|
||||
</view>
|
||||
<view class="mt-0.5 text-2.5 wot-text-text-auxiliary">
|
||||
{{ t('index.independentUser', { count: dashboardStats?.today_unique_users ?? 0 }) }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
</wd-row>
|
||||
</view>
|
||||
|
||||
<!-- 系统监控(原数据概览 dashboard 合并,Banner 点击可滚动到此区块) -->
|
||||
<view id="monitor-section" class="mt-4">
|
||||
<view class="mb-2 flex items-center gap-2 px-3">
|
||||
<view class="h-3.5 w-1 rounded-full" style="background-color: var(--danger-color, #EF4444);" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t('index.sysMonitor') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录趋势折线图 -->
|
||||
<view class="mx-3 mb-3 rounded-2 p-4 wot-bg-filled-oppo">
|
||||
<view class="mb-4 flex items-center gap-2">
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t('index.loginTrend') }}
|
||||
</text>
|
||||
</view>
|
||||
<uni-echarts custom-class="h-52 w-full" :option="loginTrendOption" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新公告 -->
|
||||
<view v-if="recentNotices.length > 0" class="mb-2 mt-4 flex items-center justify-between px-3">
|
||||
<view class="flex items-center gap-2">
|
||||
<view class="h-3.5 w-1 rounded-full" style="background-color: var(--success-color, #10B981);" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t('index.latestNotice') }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="text-3 wot-text-primary" @click="navigateTo('work-notices')">
|
||||
{{ t('common.all') }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="recentNotices.length > 0" class="mx-3">
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
v-for="item in recentNotices"
|
||||
:key="item.id"
|
||||
:title="item.notice_title"
|
||||
is-link
|
||||
@click="navigateTo('work-notices')"
|
||||
>
|
||||
<template #label>
|
||||
<text class="text-2.5 wot-text-text-auxiliary">
|
||||
{{ item.created_time || '' }}
|
||||
</text>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<wd-backtop :scroll-top="scrollTop" :top="80" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ===== 轮播 Banner(渐变装饰 + 文字布局,其余布局全部走 UnoCSS 原子类) ===== */
|
||||
.banner-slide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 36rpx 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
|
||||
/* 装饰圆环(极光浮动动效) */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -80rpx;
|
||||
top: -80rpx;
|
||||
width: 300rpx;
|
||||
height: 300rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
animation: banner-orbit 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 40rpx;
|
||||
bottom: -120rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
animation: banner-orbit 9s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
/* 极光呼吸:装饰圆环缓慢浮动,增强 Banner 层次感(transform 动画,真机性能友好) */
|
||||
@keyframes banner-orbit {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-14rpx) scale(1.06); }
|
||||
}
|
||||
|
||||
&--greet { background: linear-gradient(135deg, #4F8CFF, #2563EB); }
|
||||
&--ticket { background: linear-gradient(135deg, #F59E0B, #D97706); }
|
||||
&--ticket-empty { background: linear-gradient(135deg, #10B981, #059669); }
|
||||
&--stats { background: linear-gradient(135deg, #8B5CF6, #6D28D9); }
|
||||
|
||||
&__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
align-self: flex-start;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 8rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
margin-left: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
border-radius: 9999rpx;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||
|
||||
&-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-dots {
|
||||
position: absolute;
|
||||
right: 24rpx;
|
||||
bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
|
||||
&__dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
width: 28rpx;
|
||||
border-radius: 9999rpx;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* wd-count-to 数字统一字号 */
|
||||
:deep(.stat-count) {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 暗黑模式下首页通知栏底色改为半透明主色,避免默认深蓝色块 (#0A235C) 在暗色页面上过重;
|
||||
需要全局选择器,因为 .wot-theme-dark 挂在外层 wd-config-provider 根上 */
|
||||
.wot-theme-dark .home-notice {
|
||||
--wot-notice-bar-info-bg: rgba(68, 128, 255, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormSchema } from '@wot-ui/ui/components/wd-form/types'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import UserAPI from '@/api/module_system/user'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { REMEMBER_ME_KEY } from '@/constants'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({ name: 'forget', style: { navigationBarTitleText: '忘记密码' } })
|
||||
useI18nNavTitle('forget.navTitle')
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const submitting = ref(false)
|
||||
const forgetFormRef = ref()
|
||||
const forgetForm = reactive({
|
||||
username: '',
|
||||
new_password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
/** 与后端 UserForgetPasswordSchema 一致:字母开头,3-32 位,仅允许字母、数字、_ . - */
|
||||
const USERNAME_REG = /^[a-z][\w.-]{2,31}$/i
|
||||
|
||||
/** 表单验证 schema — 字段级错误提示(与登录页 wd-form 一致) */
|
||||
const forgetSchema: FormSchema = {
|
||||
validate: (model) => {
|
||||
const errors: Array<{ path: Array<string | number>, message: string }> = []
|
||||
const username = String(model.username ?? '').trim()
|
||||
const password = String(model.new_password ?? '')
|
||||
const confirmPassword = String(model.confirmPassword ?? '')
|
||||
if (!username)
|
||||
errors.push({ path: ['username'], message: t('common.form.usernameRequired') })
|
||||
else if (!USERNAME_REG.test(username))
|
||||
errors.push({ path: ['username'], message: t('common.form.usernameFormat') })
|
||||
if (!password)
|
||||
errors.push({ path: ['new_password'], message: t('common.form.newPasswordRequired') })
|
||||
else if (password.length < 6)
|
||||
errors.push({ path: ['new_password'], message: t('common.form.passwordLength') })
|
||||
if (!confirmPassword)
|
||||
errors.push({ path: ['confirmPassword'], message: t('common.form.confirmNewRequired') })
|
||||
else if (confirmPassword !== password)
|
||||
errors.push({ path: ['confirmPassword'], message: t('common.form.mismatch') })
|
||||
return errors
|
||||
},
|
||||
}
|
||||
|
||||
/** 重置密码成功后记住用户名,回登录页由用户用新密码登录 */
|
||||
async function handleSubmit() {
|
||||
if (submitting.value)
|
||||
return
|
||||
const { valid } = await forgetFormRef.value.validate()
|
||||
if (!valid)
|
||||
return
|
||||
|
||||
const username = forgetForm.username.trim()
|
||||
submitting.value = true
|
||||
try {
|
||||
await UserAPI.forgetPassword({ username, new_password: forgetForm.new_password })
|
||||
Storage.set(REMEMBER_ME_KEY, { username, remember: true })
|
||||
toast.success(t('forget.success'))
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示(如用户不存在)
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="forget-page">
|
||||
<view class="forget-card">
|
||||
<text class="forget-card__title">
|
||||
{{ t('forget.title') }}
|
||||
</text>
|
||||
|
||||
<view class="forget-form">
|
||||
<wd-form ref="forgetFormRef" :model="forgetForm" :schema="forgetSchema">
|
||||
<wd-form-item prop="username">
|
||||
<wd-input
|
||||
v-model="forgetForm.username"
|
||||
:placeholder="t('common.form.usernamePlaceholder')"
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="new_password">
|
||||
<wd-input
|
||||
v-model="forgetForm.new_password"
|
||||
:placeholder="t('common.form.newPasswordPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="confirmPassword">
|
||||
<wd-input
|
||||
v-model="forgetForm.confirmPassword"
|
||||
:placeholder="t('common.form.confirmNewPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
/>
|
||||
</wd-form-item>
|
||||
</wd-form>
|
||||
<wd-button type="primary" round block :loading="submitting" @click="handleSubmit">
|
||||
{{ submitting ? t('forget.submitting') : t('forget.submit') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="forget-footer">
|
||||
<text class="forget-footer__link wot-text-primary" @click="goLogin">
|
||||
{{ t('forget.toLogin') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.forget-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* H5 下 100vh 包含导航栏,使用uni-app的可用视口高度变量避免溢出 */
|
||||
/* #ifdef H5 */
|
||||
height: calc(100vh - 44px);
|
||||
/* #endif */
|
||||
/* #ifndef H5 */
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
padding: 0 64rpx;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: var(--page-bg-color, #F9F9F9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 暗黑模式下整页背景变深,消除白色断层(wot 根类为 wot-theme-dark) */
|
||||
.wot-theme-dark .forget-page {
|
||||
background: var(--bg-color-1, #0F0F11);
|
||||
}
|
||||
|
||||
.forget-card {
|
||||
width: 100%;
|
||||
margin-top: 120rpx;
|
||||
background: var(--card-bg-color, #FFFFFF);
|
||||
border-radius: var(--radius-xl, 32rpx);
|
||||
padding: 40rpx 36rpx;
|
||||
border: 2rpx solid var(--border-color, #EAECF0);
|
||||
box-shadow: var(--shadow-md, 0 8rpx 32rpx rgba(15, 23, 42, 0.04));
|
||||
|
||||
.wot-theme-dark & {
|
||||
background: var(--bg-color-3, #1A1A1A);
|
||||
border-color: var(--border-color, #2C2C2E);
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: var(--font-xl, 36rpx);
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #0A1628);
|
||||
margin-bottom: 28rpx;
|
||||
|
||||
/* 暗黑模式下使用纯白,提升卡片标题醒目度 */
|
||||
.wot-theme-dark & {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forget-form {
|
||||
:deep(.wd-input) {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
/* 去掉 wd-cell 自带左右内边距,使输入框与登录按钮同宽(与登录页一致) */
|
||||
:deep(.wd-form-item) {
|
||||
margin-bottom: 14rpx;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑下 wot 的 filled-oppo 回退纯黑,与卡片/页面层级脱节:
|
||||
cell 容器回归透明,输入框回落深灰圆角块 */
|
||||
.wot-theme-dark .forget-page :deep(.wd-cell) {
|
||||
--wot-cell-bg: transparent;
|
||||
}
|
||||
|
||||
.wot-theme-dark .forget-page :deep(.wd-input) {
|
||||
--wot-input-bg: var(--bg-color-3, #2C2C2E);
|
||||
}
|
||||
|
||||
.forget-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32rpx;
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,639 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormSchema } from '@wot-ui/ui/components/wd-form/types'
|
||||
import type { SlideVerifyInstance } from '@wot-ui/ui/components/wd-slide-verify/types'
|
||||
import type { CaptchaInfo, LoginFormData, OAuthProvider } from '@/api/module_system/auth'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AuthAPI from '@/api/module_system/auth'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { useWxLogin } from '@/composables/useWxLogin'
|
||||
import { REMEMBER_ME_KEY } from '@/constants'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({ name: 'login', style: { navigationBarTitleText: '登录' } })
|
||||
useI18nNavTitle('login.navTitle')
|
||||
|
||||
const { t } = useI18n()
|
||||
const loginFormRef = ref()
|
||||
const sliderCaptchaRef = ref<SlideVerifyInstance>()
|
||||
const loading = ref(false)
|
||||
const userStore = useUserStore()
|
||||
const configStore = useConfigStore()
|
||||
const { wxLogin, wxPhoneLogin } = useWxLogin()
|
||||
const redirect = ref('/pages/index/index')
|
||||
|
||||
/** 规范化 BASE_URL(保证以 / 结尾),用于拼接静态资源路径:H5 下避免 base '/app' 拼出 /appstatic/xxx */
|
||||
const BASE_PATH = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : `${import.meta.env.BASE_URL}/`
|
||||
|
||||
/** 品牌区参数(来自后端系统参数,带默认值兜底;web 端消费方式:configData?.[key]?.config_value) */
|
||||
const brandLogo = computed(() => configStore.configData?.logo_url?.config_value?.trim() || `${BASE_PATH}static/logo.png`)
|
||||
const brandTitle = computed(() => configStore.configData?.sys_name?.config_value?.trim() || 'FastapiAdmin')
|
||||
const brandSubtitle = computed(() => configStore.configData?.login_title?.config_value?.trim() || t('login.brandSubtitle'))
|
||||
|
||||
/** 表单验证 schema — 用户名/密码字段级错误提示(wot-ui 函数式校验) */
|
||||
const loginSchema: FormSchema = {
|
||||
validate: (model) => {
|
||||
const errors: Array<{ path: Array<string | number>, message: string }> = []
|
||||
const username = String(model.username ?? '')
|
||||
const password = String(model.password ?? '')
|
||||
if (!username)
|
||||
errors.push({ path: ['username'], message: t('common.form.usernameRequired') })
|
||||
else if (username.length < 3 || username.length > 20)
|
||||
errors.push({ path: ['username'], message: t('login.usernameLength') })
|
||||
if (!password)
|
||||
errors.push({ path: ['password'], message: t('common.form.passwordRequired') })
|
||||
else if (password.length < 6 || password.length > 20)
|
||||
errors.push({ path: ['password'], message: t('login.passwordLength') })
|
||||
return errors
|
||||
},
|
||||
}
|
||||
|
||||
const loginFormData = reactive<LoginFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captcha_key: '',
|
||||
remember: true,
|
||||
login_type: '移动端',
|
||||
})
|
||||
|
||||
const captchaState = reactive<CaptchaInfo>({ enable: false, key: '', img_base: '' })
|
||||
|
||||
/**
|
||||
* wd-slide-verify 在小程序端 onMounted 时容器可能尚未完成布局,
|
||||
* getRect 测量到 0 宽高 → maxPosition=0 → 滑块无法拖动。
|
||||
* 渲染完成后延迟重试 init(),直至组件实例就绪并重新测量。
|
||||
*/
|
||||
watch(() => captchaState.enable, async (enabled) => {
|
||||
if (!enabled)
|
||||
return
|
||||
await nextTick()
|
||||
retrySliderInit()
|
||||
})
|
||||
|
||||
function retrySliderInit(retry = 0) {
|
||||
const verify = sliderCaptchaRef.value
|
||||
if (!verify) {
|
||||
if (retry < 5)
|
||||
setTimeout(() => retrySliderInit(retry + 1), 120)
|
||||
return
|
||||
}
|
||||
verify.init()
|
||||
}
|
||||
|
||||
/** 滑块验证是否已通过(后端返回 verified=true 后置位) */
|
||||
const sliderPassed = ref(false)
|
||||
|
||||
/** 从本地存储恢复记住的用户名(仅用户名,不存储密码) */
|
||||
function restoreRememberedUser() {
|
||||
const remembered = Storage.get<{ username: string, remember: boolean }>(REMEMBER_ME_KEY)
|
||||
if (remembered) {
|
||||
loginFormData.username = remembered.username || ''
|
||||
loginFormData.remember = remembered.remember ?? true
|
||||
}
|
||||
}
|
||||
|
||||
async function getLoginCaptcha() {
|
||||
try {
|
||||
const result = await AuthAPI.getCaptcha()
|
||||
if (result && typeof result === 'object') {
|
||||
captchaState.enable = Boolean(result.enable)
|
||||
captchaState.key = result.key || ''
|
||||
captchaState.img_base = result.img_base || ''
|
||||
if (captchaState.enable) {
|
||||
loginFormData.captcha_key = captchaState.key
|
||||
// 重置滑块到初始状态
|
||||
resetSliderCaptcha()
|
||||
}
|
||||
}
|
||||
else {
|
||||
captchaState.enable = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('验证码获取失败', e)
|
||||
captchaState.enable = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* wd-slide-verify 拖动到终点触发 success — 调用后端 slider_complete 接口标记验证完成
|
||||
* 后端仅标记 captcha_key 状态为 verified,不校验 x 坐标
|
||||
*/
|
||||
async function handleSliderSuccess() {
|
||||
if (!captchaState.key) {
|
||||
uni.showToast({ title: t('login.captchaExpired'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await AuthAPI.completeSliderCaptcha({
|
||||
captcha_key: captchaState.key,
|
||||
x: 100, // 占位值,后端未使用
|
||||
})
|
||||
if (result?.verified) {
|
||||
sliderPassed.value = true
|
||||
loginFormData.captcha = 'verified' // 占位值,后端只校验 captcha_key 状态
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: t('login.sliderFailed'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('滑块验证失败', e)
|
||||
uni.showToast({ title: t('login.sliderFailed'), icon: 'none' })
|
||||
sliderCaptchaRef.value?.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置滑块验证状态并清空占位验证值(验证码刷新 / 拖动未到终点触发 fail 时) */
|
||||
function resetSliderCaptcha() {
|
||||
sliderPassed.value = false
|
||||
loginFormData.captcha = ''
|
||||
sliderCaptchaRef.value?.reset()
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const from = options?.redirect ? decodeURIComponent(options.redirect) : ''
|
||||
// 安全验证:仅允许本地页面路径,防止开放重定向攻击
|
||||
if (from && from !== '/pages/login/index' && from.startsWith('/pages/')) {
|
||||
redirect.value = from
|
||||
}
|
||||
restoreRememberedUser()
|
||||
getLoginCaptcha()
|
||||
// 拉取系统参数(品牌区 logo/标题/副标题),幂等 + 本地持久化缓存
|
||||
configStore.getConfig()
|
||||
})
|
||||
|
||||
/** 登录提交 — 防抖 + 字段验证 + 滑块校验 + 错误提示 + 记住密码 */
|
||||
async function handleSubmit() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
// 滑块验证前置校验
|
||||
if (captchaState.enable && !sliderPassed.value) {
|
||||
uni.showToast({ title: t('login.sliderRequired'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { valid } = await loginFormRef.value.validate()
|
||||
if (!valid) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
await userStore.login(loginFormData)
|
||||
// 登录成功后处理记住密码:仅存储用户名,不存储密码
|
||||
if (loginFormData.remember) {
|
||||
Storage.set(REMEMBER_ME_KEY, { username: loginFormData.username, remember: true })
|
||||
}
|
||||
else {
|
||||
Storage.remove(REMEMBER_ME_KEY)
|
||||
}
|
||||
uni.reLaunch({ url: redirect.value })
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: t('login.loginFailed'), icon: 'none', duration: 2500 })
|
||||
// 登录失败后自动刷新验证码并重置滑块
|
||||
if (captchaState.enable)
|
||||
getLoginCaptcha()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转注册页 */
|
||||
function goRegister() {
|
||||
uni.navigateTo({ url: '/pages/login/register/index' })
|
||||
}
|
||||
|
||||
/** 跳转忘记密码页 */
|
||||
function goForget() {
|
||||
uni.navigateTo({ url: '/pages/login/forget/index' })
|
||||
}
|
||||
|
||||
/** 第三方 OAuth 登录 */
|
||||
async function handleOAuth(provider: OAuthProvider) {
|
||||
try {
|
||||
const result = await AuthAPI.getOAuthLoginUrl(provider)
|
||||
if (result?.url) {
|
||||
// #ifdef H5
|
||||
window.location.href = result.url
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.setClipboardData({
|
||||
data: result.url,
|
||||
success: () => {
|
||||
uni.showToast({ title: t('login.oauthCopyTip'), icon: 'none' })
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: t('login.oauthFailed'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信一键登录(仅小程序端显示按钮) */
|
||||
const wxLoading = ref(false)
|
||||
async function handleWxLogin() {
|
||||
if (wxLoading.value)
|
||||
return
|
||||
wxLoading.value = true
|
||||
try {
|
||||
await wxLogin()
|
||||
uni.reLaunch({ url: redirect.value })
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: t('login.wxLoginFailed'), icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
wxLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信手机号登录(配合 open-type="getPhoneNumber") */
|
||||
async function handleWxPhoneLogin(e: any) {
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
uni.showToast({ title: t('login.phoneAuthCancel'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
wxLoading.value = true
|
||||
try {
|
||||
await wxPhoneLogin(e.detail.code)
|
||||
uni.reLaunch({ url: redirect.value })
|
||||
}
|
||||
catch {
|
||||
uni.showToast({ title: t('login.phoneLoginFailed'), icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
wxLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- Brand area -->
|
||||
<view class="login-brand">
|
||||
<image class="brand-logo" :src="brandLogo" mode="aspectFit" />
|
||||
<text class="brand-title">
|
||||
{{ brandTitle }}
|
||||
</text>
|
||||
<text class="brand-subtitle">
|
||||
{{ brandSubtitle }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Form card -->
|
||||
<view class="login-card">
|
||||
<text class="login-card__title">
|
||||
{{ t('login.cardTitle') }}
|
||||
</text>
|
||||
|
||||
<!-- 微信一键登录(仅小程序端显示) -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="wx-login-section">
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="wxLoading"
|
||||
round
|
||||
block
|
||||
@click="handleWxLogin"
|
||||
>
|
||||
<wd-icon name="wechat" size="18px" color="#FFFFFF" />
|
||||
<text class="ml-1">
|
||||
{{ t('login.wxLogin') }}
|
||||
</text>
|
||||
</wd-button>
|
||||
<wd-button
|
||||
plain
|
||||
round
|
||||
block
|
||||
custom-class="mt-3"
|
||||
:loading="wxLoading"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="handleWxPhoneLogin"
|
||||
>
|
||||
{{ t('login.phoneLogin') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-divider>{{ t('login.orAccount') }}</wd-divider>
|
||||
<!-- #endif -->
|
||||
|
||||
<wd-form ref="loginFormRef" :model="loginFormData" :schema="loginSchema">
|
||||
<!-- Username — wot 原生 wd-input,前缀图标走 prefix-icon 属性 -->
|
||||
<wd-form-item prop="username">
|
||||
<wd-input
|
||||
v-model="loginFormData.username"
|
||||
:placeholder="t('common.form.usernamePlaceholder')"
|
||||
clearable
|
||||
confirm-type="next"
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
@confirm="handleSubmit"
|
||||
/>
|
||||
</wd-form-item>
|
||||
|
||||
<!-- Password — show-password 自动启用密码可见性切换 -->
|
||||
<wd-form-item prop="password">
|
||||
<wd-input
|
||||
v-model="loginFormData.password"
|
||||
:placeholder="t('common.form.passwordPlaceholder')"
|
||||
clearable
|
||||
show-password
|
||||
confirm-type="go"
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
@confirm="handleSubmit"
|
||||
/>
|
||||
</wd-form-item>
|
||||
|
||||
<!-- Slider Captcha — wd-slide-verify 滑块拖动验证(条件显示) -->
|
||||
<wd-form-item v-if="captchaState.enable">
|
||||
<wd-slide-verify
|
||||
ref="sliderCaptchaRef"
|
||||
:text="t('login.sliderText')"
|
||||
:success-text="t('login.sliderSuccessText')"
|
||||
@success="handleSliderSuccess"
|
||||
@fail="resetSliderCaptcha"
|
||||
/>
|
||||
</wd-form-item>
|
||||
|
||||
<!-- 记住密码 + 忘记密码 -->
|
||||
<view class="login-options">
|
||||
<wd-checkbox v-model="loginFormData.remember" size="18px">
|
||||
{{ t('login.remember') }}
|
||||
</wd-checkbox>
|
||||
<text class="forgot-link wot-text-primary" @click="goForget">
|
||||
{{ t('login.forgot') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Submit -->
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
round
|
||||
block
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? t('login.submitting') : t('login.submit') }}
|
||||
</wd-button>
|
||||
</wd-form>
|
||||
</view>
|
||||
|
||||
<!-- OAuth Login -->
|
||||
<view class="oauth-section">
|
||||
<wd-divider>{{ t('login.thirdParty') }}</wd-divider>
|
||||
<wd-grid :column="4" clickable :gutter="10">
|
||||
<!-- WeChat -->
|
||||
<wd-grid-item @click="handleOAuth('wechat')">
|
||||
<view class="oauth-btn__icon" style="background: #07C160">
|
||||
<image class="oauth-btn__iconify" src="/static/icons/wechat.svg" />
|
||||
</view>
|
||||
<text class="oauth-btn__label">
|
||||
{{ t('login.wechat') }}
|
||||
</text>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- GitHub -->
|
||||
<wd-grid-item @click="handleOAuth('github')">
|
||||
<view class="oauth-btn__icon" style="background: #24292F">
|
||||
<image class="oauth-btn__iconify" src="/static/icons/github.svg" />
|
||||
</view>
|
||||
<text class="oauth-btn__label">
|
||||
{{ t('login.github') }}
|
||||
</text>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- Gitee -->
|
||||
<wd-grid-item @click="handleOAuth('gitee')">
|
||||
<view class="oauth-btn__icon" style="background: #C71D23">
|
||||
<image class="oauth-btn__iconify" src="/static/icons/gitee.svg" />
|
||||
</view>
|
||||
<text class="oauth-btn__label">
|
||||
{{ t('login.gitee') }}
|
||||
</text>
|
||||
</wd-grid-item>
|
||||
|
||||
<!-- QQ -->
|
||||
<wd-grid-item @click="handleOAuth('qq')">
|
||||
<view class="oauth-btn__icon" style="background: #12B7F5">
|
||||
<image class="oauth-btn__iconify" src="/static/icons/qq.svg" />
|
||||
</view>
|
||||
<text class="oauth-btn__label">
|
||||
{{ t('login.qq') }}
|
||||
</text>
|
||||
</wd-grid-item>
|
||||
</wd-grid>
|
||||
</view>
|
||||
|
||||
<!-- Footer -->
|
||||
<view class="login-footer">
|
||||
<text class="login-footer__text">
|
||||
{{ t('login.noAccount') }}
|
||||
</text>
|
||||
<text class="login-footer__link wot-text-primary" @click="goRegister">
|
||||
{{ t('login.toRegister') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* H5 下 100vh 包含导航栏,使用uni-app的可用视口高度变量避免溢出 */
|
||||
/* #ifdef H5 */
|
||||
height: calc(100vh - 44px);
|
||||
/* #endif */
|
||||
/* #ifndef H5 */
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
padding: 40rpx 64rpx 0;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
background-color: var(--page-bg-color, #F9F9F9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 暗黑模式下整页背景变深,消除白色断层(wot 根类为 wot-theme-dark) */
|
||||
.wot-theme-dark .login-page {
|
||||
background-color: var(--bg-color-1, #0F0F11);
|
||||
}
|
||||
|
||||
/* ===== Brand ===== */
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 48rpx;
|
||||
padding-bottom: 32rpx;
|
||||
gap: 10rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.brand-logo {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: var(--font-3xl, 48rpx);
|
||||
font-weight: 700;
|
||||
color: var(--text-color, #0A1628);
|
||||
|
||||
/* 暗黑模式下使用纯白,提升品牌标题醒目度 */
|
||||
.wot-theme-dark & {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
|
||||
/* 暗黑下提亮到 --text-color-2,避免 #9CA3AF 在深底上偏暗 */
|
||||
.wot-theme-dark & {
|
||||
color: var(--text-color-2, #D1D5DB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Card(不再是毛玻璃,使用纯色背景 + 与页面背景形成层级差) ===== */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: var(--card-bg-color, #FFFFFF);
|
||||
border-radius: var(--radius-xl, 32rpx);
|
||||
padding: 28rpx 36rpx;
|
||||
border: 2rpx solid var(--border-color, #EAECF0);
|
||||
box-shadow: var(--shadow-md, 0 8rpx 32rpx rgba(15, 23, 42, 0.04));
|
||||
margin-bottom: 12rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* 暗黑模式:卡片用深色 2 级,页面背景用深色 1 级,形成细微层级差 */
|
||||
.wot-theme-dark & {
|
||||
background: var(--bg-color-3, #1A1A1A);
|
||||
border-color: var(--border-color, #2C2C2E);
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: var(--font-xl, 36rpx);
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #0A1628);
|
||||
margin-bottom: 18rpx;
|
||||
|
||||
/* 暗黑模式下使用纯白,提升卡片标题醒目度 */
|
||||
.wot-theme-dark & {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框原生微调 — wot 自带底色/内边距/图标,仅补圆角 */
|
||||
:deep(.wd-input) {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
/* 暗黑下 wot 的 filled-oppo 回退纯黑,与卡片/页面层级脱节:
|
||||
cell 容器回归透明,输入框回落深灰圆角块 */
|
||||
.wot-theme-dark .login-page :deep(.wd-cell) {
|
||||
--wot-cell-bg: transparent;
|
||||
}
|
||||
|
||||
.wot-theme-dark .login-page :deep(.wd-input) {
|
||||
--wot-input-bg: var(--bg-color-3, #2C2C2E);
|
||||
}
|
||||
|
||||
:deep(.wd-form-item) {
|
||||
margin-bottom: 14rpx;
|
||||
/* 去掉 wd-cell 自带左右内边距,使输入框/滑块与登录按钮同宽 */
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* ===== 记住密码 + 忘记密码 ===== */
|
||||
.login-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
}
|
||||
|
||||
/* ===== OAuth Section ===== */
|
||||
.oauth-section {
|
||||
width: 100%;
|
||||
margin-top: 8rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* 去掉宫格项默认填充背景,仅保留图标/文字与轻量点击反馈 */
|
||||
:deep(.wd-grid-item) {
|
||||
--wot-grid-item-bg: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 微信一键登录区域 ===== */
|
||||
.wx-login-section {
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
:deep(.wd-button) {
|
||||
height: 88rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-btn__icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth-btn__iconify {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.oauth-btn__label {
|
||||
font-size: var(--font-xs, 20rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.login-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 0 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormSchema } from '@wot-ui/ui/components/wd-form/types'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AuthAPI from '@/api/module_system/auth'
|
||||
import UserAPI from '@/api/module_system/user'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { REMEMBER_ME_KEY } from '@/constants'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
definePage({ name: 'register', style: { navigationBarTitleText: '注册' } })
|
||||
useI18nNavTitle('register.navTitle')
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const submitting = ref(false)
|
||||
const agreeRead = ref(false)
|
||||
const registerFormRef = ref()
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
/** 与后端 UserRegisterSchema 一致:字母开头,3-32 位,仅允许字母、数字、_ . - */
|
||||
const USERNAME_REG = /^[a-z][\w.-]{2,31}$/i
|
||||
|
||||
/** 表单验证 schema — 字段级错误提示(与登录页 wd-form 一致) */
|
||||
const registerSchema: FormSchema = {
|
||||
validate: (model) => {
|
||||
const errors: Array<{ path: Array<string | number>, message: string }> = []
|
||||
const username = String(model.username ?? '').trim()
|
||||
const name = String(model.name ?? '').trim()
|
||||
const password = String(model.password ?? '')
|
||||
const confirmPassword = String(model.confirmPassword ?? '')
|
||||
if (!username)
|
||||
errors.push({ path: ['username'], message: t('common.form.usernameRequired') })
|
||||
else if (!USERNAME_REG.test(username))
|
||||
errors.push({ path: ['username'], message: t('common.form.usernameFormat') })
|
||||
if (!name)
|
||||
errors.push({ path: ['name'], message: t('common.form.nicknameRequired') })
|
||||
if (!password)
|
||||
errors.push({ path: ['password'], message: t('common.form.passwordRequired') })
|
||||
else if (password.length < 6)
|
||||
errors.push({ path: ['password'], message: t('common.form.passwordLength') })
|
||||
if (!confirmPassword)
|
||||
errors.push({ path: ['confirmPassword'], message: t('common.form.confirmRequired') })
|
||||
else if (confirmPassword !== password)
|
||||
errors.push({ path: ['confirmPassword'], message: t('common.form.mismatch') })
|
||||
return errors
|
||||
},
|
||||
}
|
||||
|
||||
/** 打开用户协议:H5 新窗口,其他端复制链接(与设置页链接行为一致) */
|
||||
function handleAgreementOpen() {
|
||||
const url = configStore.configData?.clause?.config_value?.trim() || ''
|
||||
if (!url) {
|
||||
toast.info(t('register.userAgreement'))
|
||||
return
|
||||
}
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
showToast: false,
|
||||
success: () => {
|
||||
uni.hideToast()
|
||||
toast.success({ msg: url })
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册成功后尝试自动登录:
|
||||
* - 系统未启用滑块验证码 → 直接登录进入首页
|
||||
* - 验证码启用 / 自动登录失败 → 记住用户名回登录页,由用户完成滑块后登录
|
||||
*/
|
||||
async function autoLoginAfterRegister(username: string, password: string) {
|
||||
try {
|
||||
const captcha = await AuthAPI.getCaptcha()
|
||||
if (captcha?.enable) {
|
||||
Storage.set(REMEMBER_ME_KEY, { username, remember: true })
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
return
|
||||
}
|
||||
await userStore.login({ username, password, remember: true, login_type: '移动端' })
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
catch {
|
||||
Storage.set(REMEMBER_ME_KEY, { username, remember: true })
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting.value)
|
||||
return
|
||||
if (!agreeRead.value) {
|
||||
toast.warning(t('register.agreeRequired'))
|
||||
return
|
||||
}
|
||||
const { valid } = await registerFormRef.value.validate()
|
||||
if (!valid)
|
||||
return
|
||||
|
||||
const username = registerForm.username.trim()
|
||||
const name = registerForm.name.trim()
|
||||
submitting.value = true
|
||||
try {
|
||||
await UserAPI.registerUser({ username, name, password: registerForm.password })
|
||||
toast.success(t('register.success'))
|
||||
await autoLoginAfterRegister(username, registerForm.password)
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示(如用户名已存在)
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
// 拉取系统参数(用户协议链接 clause),幂等 + 本地持久化缓存
|
||||
configStore.getConfig()
|
||||
})
|
||||
|
||||
function goLogin() {
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="register-page">
|
||||
<view class="register-card">
|
||||
<text class="register-card__title">
|
||||
{{ t('register.title') }}
|
||||
</text>
|
||||
|
||||
<view class="register-form">
|
||||
<wd-form ref="registerFormRef" :model="registerForm" :schema="registerSchema">
|
||||
<wd-form-item prop="username">
|
||||
<wd-input
|
||||
v-model="registerForm.username"
|
||||
:placeholder="t('common.form.usernamePlaceholder')"
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="name">
|
||||
<wd-input
|
||||
v-model="registerForm.name"
|
||||
:placeholder="t('common.form.nicknamePlaceholder')"
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="user"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="password">
|
||||
<wd-input
|
||||
v-model="registerForm.password"
|
||||
:placeholder="t('common.form.passwordPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
/>
|
||||
</wd-form-item>
|
||||
<wd-form-item prop="confirmPassword">
|
||||
<wd-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
:placeholder="t('common.form.confirmPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
:compact="false"
|
||||
prefix-icon="lock"
|
||||
/>
|
||||
</wd-form-item>
|
||||
</wd-form>
|
||||
|
||||
<!-- 用户协议勾选(与 web 端一致,注册前必须同意) -->
|
||||
<view class="register-agreement">
|
||||
<wd-checkbox v-model="agreeRead" size="18px">
|
||||
{{ t('register.agree') }}
|
||||
</wd-checkbox>
|
||||
<text class="register-agreement__link wot-text-primary" @click="handleAgreementOpen">
|
||||
{{ t('register.userAgreement') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<wd-button type="primary" round block :loading="submitting" @click="handleSubmit">
|
||||
{{ submitting ? t('register.submitting') : t('register.submit') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="register-footer">
|
||||
<text class="register-footer__text">
|
||||
{{ t('register.hasAccount') }}
|
||||
</text>
|
||||
<text class="register-footer__link wot-text-primary" @click="goLogin">
|
||||
{{ t('register.toLogin') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.register-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* H5 下 100vh 包含导航栏,使用uni-app的可用视口高度变量避免溢出 */
|
||||
/* #ifdef H5 */
|
||||
height: calc(100vh - 44px);
|
||||
/* #endif */
|
||||
/* #ifndef H5 */
|
||||
height: 100vh;
|
||||
/* #endif */
|
||||
padding: 0 64rpx;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
background: var(--page-bg-color, #F9F9F9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 暗黑模式下整页背景变深,消除白色断层(wot 根类为 wot-theme-dark) */
|
||||
.wot-theme-dark .register-page {
|
||||
background: var(--bg-color-1, #0F0F11);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
margin-top: 120rpx;
|
||||
background: var(--card-bg-color, #FFFFFF);
|
||||
border-radius: var(--radius-xl, 32rpx);
|
||||
padding: 40rpx 36rpx;
|
||||
border: 2rpx solid var(--border-color, #EAECF0);
|
||||
box-shadow: var(--shadow-md, 0 8rpx 32rpx rgba(15, 23, 42, 0.04));
|
||||
|
||||
.wot-theme-dark & {
|
||||
background: var(--bg-color-3, #1A1A1A);
|
||||
border-color: var(--border-color, #2C2C2E);
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: var(--font-xl, 36rpx);
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #0A1628);
|
||||
margin-bottom: 28rpx;
|
||||
|
||||
/* 暗黑模式下使用纯白,提升卡片标题醒目度 */
|
||||
.wot-theme-dark & {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
:deep(.wd-input) {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
/* 去掉 wd-cell 自带左右内边距,使输入框与登录按钮同宽(与登录页一致) */
|
||||
:deep(.wd-form-item) {
|
||||
margin-bottom: 14rpx;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑下 wot 的 filled-oppo 回退纯黑,与卡片/页面层级脱节:
|
||||
cell 容器回归透明,输入框回落深灰圆角块 */
|
||||
.wot-theme-dark .register-page :deep(.wd-cell) {
|
||||
--wot-cell-bg: transparent;
|
||||
}
|
||||
|
||||
.wot-theme-dark .register-page :deep(.wd-input) {
|
||||
--wot-input-bg: var(--bg-color-3, #2C2C2E);
|
||||
}
|
||||
|
||||
.register-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
:deep(.wd-checkbox__label) {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 32rpx;
|
||||
|
||||
&__text {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
color: var(--text-color-3, #6B7280);
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: var(--font-md, 28rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PasswordChangeForm } from '@/api/module_system/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import UserAPI from '@/api/module_system/user'
|
||||
import { getTicketStats } from '@/composables/useCachedRequest'
|
||||
import { useGlobalDialog } from '@/composables/useGlobalDialog'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
useShare(() => ({
|
||||
title: t('mine.shareTitle', { name: userInfo.value?.name || t('common.admin') }),
|
||||
path: '/pages/index/index',
|
||||
}))
|
||||
|
||||
definePage({
|
||||
name: 'mine',
|
||||
layout: 'tabbar',
|
||||
style: { navigationBarTitleText: '我的' },
|
||||
})
|
||||
useI18nNavTitle('mine.navTitle')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function navigateTo(name: string) {
|
||||
router.push({ name })
|
||||
}
|
||||
|
||||
const globalDialog = useGlobalDialog()
|
||||
|
||||
function handleLogout() {
|
||||
globalDialog.confirm({
|
||||
title: t('common.title'),
|
||||
msg: t('mine.logoutMsg'),
|
||||
confirmButtonText: t('mine.logoutConfirm'),
|
||||
success: (res) => {
|
||||
if (res.action === 'confirm')
|
||||
userStore.logout()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 工单统计(共享缓存,与 work 页面复用,30秒内不重复请求) */
|
||||
const pendingTickets = ref<number | null>(null)
|
||||
const processingTickets = ref<number | null>(null)
|
||||
const doneTickets = ref<number | null>(null)
|
||||
|
||||
async function loadTicketStats() {
|
||||
try {
|
||||
const stats = await getTicketStats()
|
||||
pendingTickets.value = stats.pending
|
||||
processingTickets.value = stats.processing
|
||||
doneTickets.value = stats.done
|
||||
}
|
||||
catch { /* silent */ }
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0 && pages[pages.length - 1].route === 'pages/mine/index') {
|
||||
uni.$emit('updateTabbar', 'mine')
|
||||
loadTicketStats()
|
||||
}
|
||||
})
|
||||
|
||||
const settingsList = [
|
||||
{ titleKey: 'common.profile', name: 'profile' },
|
||||
{ titleKey: 'mine.changePassword', name: 'account', dialog: true },
|
||||
{ titleKey: 'common.aboutUs', name: 'about' },
|
||||
]
|
||||
|
||||
const quickLinks = [
|
||||
{ titleKey: 'common.nav.notices', name: 'work-notices', icon: 'notification', color: '#10B981' },
|
||||
{ titleKey: 'common.nav.tickets', name: 'work-tickets', icon: 'message', color: '#F59E0B' },
|
||||
{ titleKey: 'common.nav.aiChat', name: 'work-chat', icon: 'interaction', color: '#06B6D4' },
|
||||
{ titleKey: 'common.nav.aiModels', name: 'work-ai-models', icon: 'robot', color: '#8B5CF6' },
|
||||
]
|
||||
|
||||
/** 修改密码弹窗(内嵌于「我的」页,免跳转) */
|
||||
const toast = useToast()
|
||||
const showPasswordDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const passwordForm = reactive<PasswordChangeForm>({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
function openPasswordDialog() {
|
||||
passwordForm.old_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
/** 提交修改密码:字段级校验 + 调用后端接口 */
|
||||
async function handleSubmitPassword() {
|
||||
if (!passwordForm.old_password) {
|
||||
toast.warning(t('account.oldRequired'))
|
||||
return
|
||||
}
|
||||
if (!passwordForm.new_password) {
|
||||
toast.warning(t('common.form.newPasswordRequired'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.new_password.length < 6) {
|
||||
toast.warning(t('account.newLength'))
|
||||
return
|
||||
}
|
||||
if (!passwordForm.confirm_password) {
|
||||
toast.warning(t('common.form.confirmNewRequired'))
|
||||
return
|
||||
}
|
||||
if (passwordForm.confirm_password !== passwordForm.new_password) {
|
||||
toast.warning(t('common.form.mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await UserAPI.changeCurrentUserPassword({
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password,
|
||||
confirm_password: passwordForm.confirm_password,
|
||||
})
|
||||
toast.success(t('account.success'))
|
||||
showPasswordDialog.value = false
|
||||
}
|
||||
catch {
|
||||
// http 层已统一错误提示
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page-wraper py-3">
|
||||
<!-- 用户信息卡(品牌渐变 + 极光装饰圆环,与工作台一致) -->
|
||||
<view class="mine-user-card mx-3 mb-3 flex items-center gap-4 rounded-3 px-5 py-6">
|
||||
<wd-badge is-dot>
|
||||
<wd-avatar
|
||||
size="64px"
|
||||
round
|
||||
:src="userInfo?.avatar || ''"
|
||||
icon="user"
|
||||
/>
|
||||
</wd-badge>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="text-4 text-white font-bold">
|
||||
{{ userInfo?.name || t('common.admin') }}
|
||||
</view>
|
||||
<view class="mt-1 truncate text-3" style="color: rgba(255, 255, 255, 0.75);">
|
||||
{{ userInfo?.roles?.map(r => r.name).join(', ') || t('mine.roleFallback') }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 设置入口(纯 icon,打开设置页:内含主题设置) -->
|
||||
<view
|
||||
class="relative z-10 h-9 w-9 flex shrink-0 items-center justify-center rounded-full active:opacity-70"
|
||||
hover-class="none"
|
||||
@click="navigateTo('setting')"
|
||||
>
|
||||
<wd-icon name="settings" size="20px" color="rgba(255, 255, 255, 0.9)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 工单统计 -->
|
||||
<view class="mx-3 mb-3">
|
||||
<wd-row :gutter="12">
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo active:opacity-70" @click="navigateTo('work-tickets')">
|
||||
<view class="text-5 font-bold" style="color: var(--warning-color, #F59E0B);">
|
||||
{{ pendingTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('mine.pendingTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo active:opacity-70" @click="navigateTo('work-tickets')">
|
||||
<view class="text-5 font-bold" style="color: var(--primary-color, #4F8CFF);">
|
||||
{{ processingTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('mine.processingTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
<wd-col :span="8">
|
||||
<view class="rounded-2 p-4 text-center wot-bg-filled-oppo active:opacity-70" @click="navigateTo('work-tickets')">
|
||||
<view class="text-5 font-bold" style="color: var(--success-color, #10B981);">
|
||||
{{ doneTickets ?? '-' }}
|
||||
</view>
|
||||
<view class="mt-1 text-3 wot-text-text-secondary">
|
||||
{{ t('mine.doneTickets') }}
|
||||
</view>
|
||||
</view>
|
||||
</wd-col>
|
||||
</wd-row>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="mx-3 mb-3 rounded-2 p-2 wot-bg-filled-oppo">
|
||||
<wd-grid :column="4" :border="false" clickable>
|
||||
<wd-grid-item
|
||||
v-for="item in quickLinks.slice(0, 4)"
|
||||
:key="item.name"
|
||||
@click="navigateTo(item.name)"
|
||||
>
|
||||
<view
|
||||
class="h-11 w-11 flex items-center justify-center rounded-xl"
|
||||
:style="{ backgroundColor: `${item.color}1a` }"
|
||||
>
|
||||
<wd-icon :name="item.icon" size="20px" :color="item.color" />
|
||||
</view>
|
||||
<view class="mt-1 text-2.5 wot-text-text-secondary">
|
||||
{{ t(item.titleKey) }}
|
||||
</view>
|
||||
</wd-grid-item>
|
||||
</wd-grid>
|
||||
</view>
|
||||
|
||||
<!-- 设置列表 -->
|
||||
<view class="mx-3 mb-3">
|
||||
<view class="mb-2 mt-1 flex items-center gap-2 px-3">
|
||||
<view class="h-3.5 w-1 rounded-full" style="background-color: var(--primary-color, #4F8CFF);" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t('common.settings') }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-cell-group border custom-class="rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
v-for="item in settingsList"
|
||||
:key="item.name"
|
||||
:title="t(item.titleKey)"
|
||||
is-link
|
||||
@click="item.dialog ? openPasswordDialog() : navigateTo(item.name)"
|
||||
/>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="mx-3">
|
||||
<wd-button type="danger" plain round block @click="handleLogout">
|
||||
{{ t('mine.logout') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<wd-popup
|
||||
v-model="showPasswordDialog"
|
||||
position="bottom"
|
||||
round
|
||||
:z-index="1100"
|
||||
custom-style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
<view class="px-4 pb-4 pt-4">
|
||||
<view class="mb-4 text-center text-4 font-bold wot-text-text-main">
|
||||
{{ t('account.password') }}
|
||||
</view>
|
||||
<wd-input
|
||||
v-model="passwordForm.old_password"
|
||||
:label="t('account.oldPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="t('account.oldPlaceholder')"
|
||||
/>
|
||||
<view class="h-3" />
|
||||
<wd-input
|
||||
v-model="passwordForm.new_password"
|
||||
:label="t('account.newPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="t('common.form.newPasswordPlaceholder')"
|
||||
/>
|
||||
<view class="h-3" />
|
||||
<wd-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
:label="t('account.confirmPassword')"
|
||||
show-password
|
||||
clearable
|
||||
:placeholder="t('common.form.confirmNewPlaceholder')"
|
||||
/>
|
||||
<view class="mt-5">
|
||||
<wd-button type="primary" round block :loading="submitting" @click="handleSubmitPassword">
|
||||
{{ t('account.submit') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-gap :height="20" />
|
||||
</wd-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 用户信息卡:品牌渐变 + 极光装饰圆环(与工作台一致) */
|
||||
.mine-user-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #4F8CFF 0%, #2563EB 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.25);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -60rpx;
|
||||
top: -70rpx;
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 60rpx;
|
||||
bottom: -90rpx;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getTicketStats } from '@/composables/useCachedRequest'
|
||||
import { useI18nNavTitle } from '@/composables/useI18nNavTitle'
|
||||
import { useShare } from '@/composables/useShare'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useShare({
|
||||
title: t('work.shareTitle'),
|
||||
path: '/pages/work/index',
|
||||
})
|
||||
|
||||
definePage({
|
||||
name: 'work',
|
||||
layout: 'tabbar',
|
||||
style: { navigationBarTitleText: '工作台' },
|
||||
})
|
||||
useI18nNavTitle('work.navTitle')
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
function navigateTo(name: string) {
|
||||
router.push({ name })
|
||||
}
|
||||
|
||||
/** 工单统计(共享缓存,与 mine 页面复用,30秒内不重复请求) */
|
||||
const pendingTickets = ref<number | null>(null)
|
||||
const processingTickets = ref<number | null>(null)
|
||||
const doneTickets = ref<number | null>(null)
|
||||
|
||||
async function loadTicketStats() {
|
||||
try {
|
||||
const stats = await getTicketStats()
|
||||
pendingTickets.value = stats.pending
|
||||
processingTickets.value = stats.processing
|
||||
doneTickets.value = stats.done
|
||||
}
|
||||
catch { /* silent */ }
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0 && pages[pages.length - 1].route === 'pages/work/index') {
|
||||
uni.$emit('updateTabbar', 'work')
|
||||
loadTicketStats()
|
||||
}
|
||||
})
|
||||
|
||||
const groups = [
|
||||
{
|
||||
titleKey: 'work.businessCenter',
|
||||
color: '#F59E0B',
|
||||
bg: 'var(--warning-color-light)',
|
||||
items: [
|
||||
{ icon: 'notification', titleKey: 'common.nav.notices', name: 'work-notices', color: '#10B981' },
|
||||
{ icon: 'message', titleKey: 'common.nav.tickets', name: 'work-tickets', color: '#F59E0B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'work.devTools',
|
||||
color: '#8B5CF6',
|
||||
bg: 'var(--bg-color-2)',
|
||||
items: [
|
||||
{ icon: 'message', titleKey: 'common.nav.aiChat', name: 'work-chat' },
|
||||
{ icon: 'robot', titleKey: 'common.nav.aiModels', name: 'work-ai-models' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 搜索关键词,本地过滤模块分组 */
|
||||
const keyword = ref('')
|
||||
const filteredGroups = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw)
|
||||
return groups
|
||||
return groups
|
||||
.map(group => ({ ...group, items: group.items.filter(item => t(item.titleKey).toLowerCase().includes(kw)) }))
|
||||
.filter(group => group.items.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page-wraper py-3">
|
||||
<!-- 用户信息卡(品牌渐变 + 极光装饰圆环) -->
|
||||
<view class="work-user-card mx-3 mb-4 flex items-center gap-4 rounded-3 px-5 py-6">
|
||||
<wd-avatar
|
||||
size="64px"
|
||||
round
|
||||
:src="userInfo?.avatar || ''"
|
||||
icon="user"
|
||||
/>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="text-4 text-white font-bold">
|
||||
{{ userInfo?.name || t('work.nameFallback') }}
|
||||
</view>
|
||||
<view class="mt-1 truncate text-3" style="color: rgba(255, 255, 255, 0.75);">
|
||||
{{ userInfo?.roles?.map(r => r.name).join(', ') || t('work.roleFallback') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模块搜索 -->
|
||||
<view class="mx-3 mb-4">
|
||||
<wd-search v-model="keyword" :placeholder="t('work.searchPlaceholder')" variant="light" hide-cancel />
|
||||
</view>
|
||||
|
||||
<!-- 模块分组 -->
|
||||
<view v-for="(group, gi) in filteredGroups" :key="gi" class="mb-4">
|
||||
<view class="mb-2 mt-1 flex items-center gap-2 px-3">
|
||||
<view class="h-3.5 w-1 rounded-full" :style="{ backgroundColor: group.color }" />
|
||||
<text class="text-3.5 font-bold wot-text-text-main">
|
||||
{{ t(group.titleKey) }}
|
||||
</text>
|
||||
<text class="text-2.5 wot-text-text-auxiliary">
|
||||
{{ group.items.length }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-cell-group border custom-class="mx-3 rounded-2! overflow-hidden">
|
||||
<wd-cell
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
center
|
||||
is-link
|
||||
@click="navigateTo(item.name)"
|
||||
>
|
||||
<template #title>
|
||||
<view class="flex items-center gap-2.5">
|
||||
<view
|
||||
class="h-8 w-8 flex shrink-0 items-center justify-center rounded-lg"
|
||||
:style="{ backgroundColor: group.bg }"
|
||||
>
|
||||
<wd-icon :name="item.icon" size="16px" :color="group.color" />
|
||||
</view>
|
||||
<text>{{ t(item.titleKey) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</wd-cell>
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
<!-- 搜索无结果 -->
|
||||
<wd-empty v-if="filteredGroups.length === 0" :tip="t('work.emptyTip')" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 用户信息卡:品牌渐变 + 极光装饰圆环(与首页 Banner 视觉呼应) */
|
||||
.work-user-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #4F8CFF 0%, #2563EB 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.25);
|
||||
|
||||
/* 右上角装饰圆环 */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -60rpx;
|
||||
top: -70rpx;
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 60rpx;
|
||||
bottom: -90rpx;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user